diff --git a/.config/ci.yml b/.config/ci.yml index ae10b19e39..28186f93dc 100644 --- a/.config/ci.yml +++ b/.config/ci.yml @@ -1,7 +1,78 @@ #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Misskey configuration +# Sharkey configuration #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# ┌──────────────────────────────┐ +#───┘ a boring but important thing └──────────────────────────── + +# +# First of all, let me tell you a story that may possibly be +# boring to you and possibly important to you. +# +# Sharkey is licensed under the AGPLv3 license. This license is +# known to be often misunderstood. Please read the following +# instructions carefully and select the appropriate option so +# that you do not negligently cause a license violation. +# + +# -------- +# Option 1: If you host Sharkey AS-IS (without any changes to +# the source code. forks are not included). +# +# Step 1: Congratulations! You don't need to do anything. + +# -------- +# Option 2: If you have made changes to the source code (forks +# are included) and publish a Git repository of source +# code. There should be no access restrictions on +# this repository. Strictly speaking, it doesn't have +# to be a Git repository, but you'll probably use Git! +# +# Step 1: Build and run the Sharkey server first. +# Step 2: Open in +# your browser with the administrator account. +# Step 3: Enter the URL of your Git repository in the +# "Repository URL" field. + +# -------- +# Option 3: If neither of the above applies to you. +# (In this case, the source code should be published +# on the Sharkey interface. IT IS NOT ENOUGH TO +# DISCLOSE THE SOURCE CODE WHEN A USER REQUESTS IT BY +# E-MAIL OR OTHER MEANS. If you are not satisfied +# with this, it is recommended that you read the +# license again carefully. Anyway, enabling this +# option will automatically generate and publish a +# tarball at build time, protecting you from +# inadvertent license violations. (There is no legal +# guarantee, of course.) The tarball will generated +# from the root directory of your codebase. So it is +# also recommended to check directory +# once after building and before activating the server +# to avoid ACCIDENTAL LEAKING OF SENSITIVE INFORMATION. +# To prevent certain files from being included in the +# tarball, add a glob pattern after line 15 in +# . DO NOT FORGET TO BUILD AFTER +# ENABLING THIS OPTION!) +# +# Step 1: Uncomment the following line. +# +# publishTarballInsteadOfProvideRepositoryUrl: true + +# ┌────────────────────────┐ +#───┘ Initial Setup Password └───────────────────────────────────────────────────── + +# Password to initiate setting up admin account. +# It will not be used after the initial setup is complete. +# +# Be sure to change this when you set up Sharkey via the Internet. +# +# The provider of the service who sets up Sharkey on behalf of the customer should +# set this value to something unique when generating the Sharkey config file, +# and provide it to the customer. +# +# setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── @@ -15,11 +86,11 @@ url: http://misskey.local #───┘ Port and TLS settings └─────────────────────────────────── # -# Misskey requires a reverse proxy to support HTTPS connections. +# Sharkey requires a reverse proxy to support HTTPS connections. # # +----- https://example.tld/ ------------+ # +------+ |+-------------+ +----------------+| -# | User | ---> || Proxy (443) | ---> | Misskey (3000) || +# | User | ---> || Proxy (443) | ---> | Sharkey (3000) || # +------+ |+-------------+ +----------------+| # +---------------------------------------+ # @@ -27,7 +98,7 @@ url: http://misskey.local # 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. +# The port that your Sharkey server should listen on. port: 3000 # ┌──────────────────────────┐ @@ -44,12 +115,21 @@ db: user: postgres pass: ci - # Whether disable Caching queries - #disableCache: true + ## Log a warning to the server console if any query takes longer than this to complete. + ## Measured in milliseconds; set to 0 to disable. (default: 300) + #slowQueryThreshold: 300 + + # If false, then query results will be cached in redis. + # If true (default), then queries will not be cached. + # This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior. + #disableCache: false # Extra Connection options #extra: # ssl: true + # # Set a higher value if you have timeout issues during migration + # statement_timeout: 10000 + dbReplications: false @@ -223,6 +303,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy #mediaProxy: https://example.com/proxy @@ -232,7 +316,7 @@ proxyRemoteFiles: true # Movie Thumbnail Generation URL # There is no reference implementation. -# For example, Misskey will point to the following URL: +# For example, Sharkey 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 @@ -243,12 +327,28 @@ signToActivityPubGet: true # When using authorized fetch, this is often undesired as any signed activity can be forwarded to a blocked instance by relays and other instances. # This setting allows admins to disable LD signatures for increased privacy, at the expense of fewer relayed activities and additional inbound fetch (GET) requests. attachLdSignatureForRelays: 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)). +# Some example configurations: +#allowedPrivateNetworks: +# # Allow connections to 127.0.0.1 on any port +# - '127.0.0.1/32' +# # Allow connections to 127.0.0.* on any port +# - '127.0.0.1/24' +# # Allow connections to 127.0.0.1 on any port +# - '127.0.0.1' +# # Allow connections to 127.0.0.1 on any port +# - network: '127.0.0.1' +# # Allow connections to 127.0.0.1 on port 80 +# - network: '127.0.0.1' +# ports: [80] +# # Allow connections to 127.0.0.1 on port 80 or 443 +# - network: '127.0.0.1' +# ports: +# - 80 +# - 443 allowedPrivateNetworks: [ '127.0.0.1/32', '192.168.65.0/24' @@ -256,6 +356,11 @@ allowedPrivateNetworks: [ #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 @@ -273,6 +378,9 @@ allowedPrivateNetworks: [ # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false +# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable. +# # default: false in production, true otherwise. +# #verbose: false # Settings for the activity logger, which records inbound activities to the database. # Disabled by default due to the large volume of data it saves. diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index 9e4961c325..97263da68f 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -57,12 +57,21 @@ db: user: postgres pass: postgres - # Whether disable Caching queries - #disableCache: true + ## Log a warning to the server console if any query takes longer than this to complete. + ## Measured in milliseconds; set to 0 to disable. (default: 300) + #slowQueryThreshold: 300 + + # If false, then query results will be cached in redis. + # If true (default), then queries will not be cached. + # This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior. + #disableCache: false # Extra Connection options #extra: # ssl: true + # # Set a higher value if you have timeout issues during migration + # statement_timeout: 10000 + dbReplications: false @@ -197,6 +206,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' @@ -252,6 +266,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy #mediaProxy: https://example.com/proxy @@ -261,9 +279,32 @@ proxyRemoteFiles: true # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true -allowedPrivateNetworks: [ - '127.0.0.1/32' -] +# 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)). +# Some example configurations: +allowedPrivateNetworks: + # Allow connections to 127.0.0.1 on any port + - '127.0.0.1/32' +# # Allow connections to 127.0.0.* on any port +# - '127.0.0.1/24' +# # Allow connections to 127.0.0.1 on any port +# - '127.0.0.1' +# # Allow connections to 127.0.0.1 on any port +# - network: '127.0.0.1' +# # Allow connections to 127.0.0.1 on port 80 +# - network: '127.0.0.1' +# ports: [80] +# # Allow connections to 127.0.0.1 on port 80 or 443 +# - network: '127.0.0.1' +# ports: +# - 80 +# - 443 + +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true # Upload or download file size limits (bytes) #maxFileSize: 262144000 @@ -282,6 +323,9 @@ allowedPrivateNetworks: [ # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false +# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable. +# # default: false in production, true otherwise. +# #verbose: false # Settings for the activity logger, which records inbound activities to the database. # Disabled by default due to the large volume of data it saves. diff --git a/.config/docker_example.yml b/.config/docker_example.yml index f798fd8246..3aaa56e333 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -1,5 +1,5 @@ #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Misskey configuration +# Sharkey configuration #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ┌──────────────────────────────┐ @@ -9,14 +9,14 @@ # First of all, let me tell you a story that may possibly be # boring to you and possibly important to you. # -# Misskey is licensed under the AGPLv3 license. This license is +# Sharkey is licensed under the AGPLv3 license. This license is # known to be often misunderstood. Please read the following # instructions carefully and select the appropriate option so # that you do not negligently cause a license violation. # # -------- -# Option 1: If you host Misskey AS-IS (without any changes to +# Option 1: If you host Sharkey AS-IS (without any changes to # the source code. forks are not included). # # Step 1: Congratulations! You don't need to do anything. @@ -28,8 +28,8 @@ # this repository. Strictly speaking, it doesn't have # to be a Git repository, but you'll probably use Git! # -# Step 1: Build and run the Misskey server first. -# Step 2: Open in +# Step 1: Build and run the Sharkey server first. +# Step 2: Open in # your browser with the administrator account. # Step 3: Enter the URL of your Git repository in the # "Repository URL" field. @@ -37,7 +37,7 @@ # -------- # Option 3: If neither of the above applies to you. # (In this case, the source code should be published -# on the Misskey interface. IT IS NOT ENOUGH TO +# on the Sharkey interface. IT IS NOT ENOUGH TO # DISCLOSE THE SOURCE CODE WHEN A USER REQUESTS IT BY # E-MAIL OR OTHER MEANS. If you are not satisfied # with this, it is recommended that you read the @@ -59,6 +59,20 @@ # # publishTarballInsteadOfProvideRepositoryUrl: true +# ┌────────────────────────┐ +#───┘ Initial Setup Password └───────────────────────────────────────────────────── + +# Password to initiate setting up admin account. +# It will not be used after the initial setup is complete. +# +# Be sure to change this when you set up Sharkey via the Internet. +# +# The provider of the service who sets up Sharkey on behalf of the customer should +# set this value to something unique when generating the Sharkey config file, +# and provide it to the customer. +# +# setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── @@ -73,11 +87,11 @@ url: https://example.tld/ #───┘ Port and TLS settings └─────────────────────────────────── # -# Misskey requires a reverse proxy to support HTTPS connections. +# Sharkey requires a reverse proxy to support HTTPS connections. # # +----- https://example.tld/ ------------+ # +------+ |+-------------+ +----------------+| -# | User | ---> || Proxy (443) | ---> | Misskey (3000) || +# | User | ---> || Proxy (443) | ---> | Sharkey (3000) || # +------+ |+-------------+ +----------------+| # +---------------------------------------+ # @@ -85,7 +99,7 @@ url: https://example.tld/ # 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. +# The port that your Sharkey server should listen on. port: 3000 # ┌──────────────────────────┐ @@ -104,12 +118,21 @@ db: user: example-misskey-user pass: example-misskey-pass - # Whether disable Caching queries - #disableCache: true + ## Log a warning to the server console if any query takes longer than this to complete. + ## Measured in milliseconds; set to 0 to disable. (default: 300) + #slowQueryThreshold: 300 + + # If false, then query results will be cached in redis. + # If true (default), then queries will not be cached. + # This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior. + #disableCache: false # Extra Connection options #extra: # ssl: true + # # Set a higher value if you have timeout issues during migration + # statement_timeout: 10000 + dbReplications: false @@ -138,6 +161,8 @@ redis: #pass: example-pass #prefix: example-prefix #db: 1 + # You can specify more ioredis options... + #username: example-username #redisForPubsub: # host: redis @@ -146,6 +171,8 @@ redis: # #pass: example-pass # #prefix: example-prefix # #db: 1 +# # You can specify more ioredis options... +# #username: example-username #redisForJobQueue: # host: redis @@ -154,6 +181,8 @@ redis: # #pass: example-pass # #prefix: example-prefix # #db: 1 +# # You can specify more ioredis options... +# #username: example-username #redisForTimelines: # host: redis @@ -162,6 +191,8 @@ redis: # #pass: example-pass # #prefix: example-prefix # #db: 1 +# # You can specify more ioredis options... +# #username: example-username #redisForReactions: # host: redis @@ -170,6 +201,8 @@ redis: # #pass: example-pass # #prefix: example-prefix # #db: 1 +# # You can specify more ioredis options... +# #username: example-username #redisForRateLimit: # host: localhost @@ -195,6 +228,19 @@ fulltextSearch: # You need to install pgroonga and configure it as a PostgreSQL extension. # In addition to the above, you need to create a pgroonga index on the text column of the note table. # see: https://pgroonga.github.io/tutorial/ + # - sqlTsvector + # Use Postgres tsvectors. + # You need to create a generated column and index on the note table to use this, followed by an ANALYZE on the table. Beware, this will take a while to be created and the database will remain locked during this process. + # This also enables advanced search syntax, see documentation of websearch_to_tsquery: https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + # Support for multiple languages is currently rather poor and will be improved once post languages become a feature. + # + # Example to set up tsvectors for an English instance: + # ALTER TABLE note ADD COLUMN tsvector_embedding tsvector GENERATED ALWAYS AS ( to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(cw, '') || ' ' || COALESCE(name, ''))) STORED; + # CREATE INDEX vector_idx ON note USING GIN (tsvector_embedding); + # ANALYZE note; + # + # Note: You can opt to use a different dictionary for better results if your main instance language is not English. + # To get a list, use "SELECT cfgname FROM pg_ts_config;" and replace 'english' with the desired dictionary name. # - meilisearch # Use Meilisearch. # You need to install Meilisearch and configure. @@ -244,6 +290,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' @@ -257,20 +308,20 @@ id: 'aidx' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 -# relationshipJobConcurrency: 16 -# What's relationshipJob?: -# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. +#deliverJobConcurrency: 128 +#inboxJobConcurrency: 16 +#relationshipJobConcurrency: 16 +# What's relationshipJob?: +# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 32 -# relationshipJobPerSec: 64 +#deliverJobPerSec: 128 +#inboxJobPerSec: 32 +#relationshipJobPerSec: 64 # Job attempts -# deliverJobMaxAttempts: 12 -# inboxJobMaxAttempts: 8 +#deliverJobMaxAttempts: 12 +#inboxJobMaxAttempts: 8 # Local address used for outgoing requests #outgoingAddress: 127.0.0.1 @@ -306,7 +357,14 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # 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) @@ -315,7 +373,7 @@ proxyRemoteFiles: true # Movie Thumbnail Generation URL # There is no reference implementation. -# For example, Misskey will point to the following URL: +# For example, Sharkey 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 @@ -326,21 +384,44 @@ signToActivityPubGet: true # When using authorized fetch, this is often undesired as any signed activity can be forwarded to a blocked instance by relays and other instances. # This setting allows admins to disable LD signatures for increased privacy, at the expense of fewer relayed activities and additional inbound fetch (GET) requests. attachLdSignatureForRelays: 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' -#] +# Some example configurations: +#allowedPrivateNetworks: +# # Allow connections to 127.0.0.1 on any port +# - '127.0.0.1/32' +# # Allow connections to 127.0.0.* on any port +# - '127.0.0.1/24' +# # Allow connections to 127.0.0.1 on any port +# - '127.0.0.1' +# # Allow connections to 127.0.0.1 on any port +# - network: '127.0.0.1' +# # Allow connections to 127.0.0.1 on port 80 +# - network: '127.0.0.1' +# ports: [80] +# # Allow connections to 127.0.0.1 on port 80 or 443 +# - network: '127.0.0.1' +# ports: +# - 80 +# - 443 #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 +# timeout (in milliseconds) and maximum size for imports (e.g. note imports) +#import: +# downloadTimeout: 30000 +# maxFileSize: 262144000 + # CHMod-style permission bits to apply to uploaded files. # Permission bits are specified as a base-8 string representing User/Group/Other permissions. # This setting is only useful for custom deployments, such as using a reverse proxy to serve media. @@ -355,6 +436,9 @@ checkActivityPubGetSignature: false # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false +# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable. +# # default: false in production, true otherwise. +# #verbose: false # Settings for the activity logger, which records inbound activities to the database. # Disabled by default due to the large volume of data it saves. @@ -370,3 +454,17 @@ checkActivityPubGetSignature: false # How long to save each log entry before deleting it. # Default: 2592000000 (1 week) #maxAge: 2592000000 + +# Transparently compress every websocket message on clients that support it. +# Trades server CPU usage for reduced bandwidth usage and a faster frontend on the client. +# If you're not using jemalloc, this may cause memory fragmentation and performance issues! (https://www.npmjs.com/package/ws#websocket-compression) +# jemalloc is used by default in the Sharkey Docker image and may be set up manually otherwise: https://github.com/jemalloc/jemalloc/wiki/getting-started +websocketCompression: false + +# Inject arbitrary HTML tags to customize Sharkey without having to fork it +#customHtml: +# head: | +# +# +# +# diff --git a/.config/example.yml b/.config/example.yml index d199544589..8cac42c050 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -1,5 +1,5 @@ #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Misskey configuration +# Sharkey configuration #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ┌──────────────────────────────┐ @@ -9,14 +9,14 @@ # First of all, let me tell you a story that may possibly be # boring to you and possibly important to you. # -# Misskey is licensed under the AGPLv3 license. This license is +# Sharkey is licensed under the AGPLv3 license. This license is # known to be often misunderstood. Please read the following # instructions carefully and select the appropriate option so # that you do not negligently cause a license violation. # # -------- -# Option 1: If you host Misskey AS-IS (without any changes to +# Option 1: If you host Sharkey AS-IS (without any changes to # the source code. forks are not included). # # Step 1: Congratulations! You don't need to do anything. @@ -28,8 +28,8 @@ # this repository. Strictly speaking, it doesn't have # to be a Git repository, but you'll probably use Git! # -# Step 1: Build and run the Misskey server first. -# Step 2: Open in +# Step 1: Build and run the Sharkey server first. +# Step 2: Open in # your browser with the administrator account. # Step 3: Enter the URL of your Git repository in the # "Repository URL" field. @@ -37,7 +37,7 @@ # -------- # Option 3: If neither of the above applies to you. # (In this case, the source code should be published -# on the Misskey interface. IT IS NOT ENOUGH TO +# on the Sharkey interface. IT IS NOT ENOUGH TO # DISCLOSE THE SOURCE CODE WHEN A USER REQUESTS IT BY # E-MAIL OR OTHER MEANS. If you are not satisfied # with this, it is recommended that you read the @@ -45,7 +45,7 @@ # option will automatically generate and publish a # tarball at build time, protecting you from # inadvertent license violations. (There is no legal -# guarantee, of course.) The tarball will generated +# guarantee, of course.) The tarball will be generated # from the root directory of your codebase. So it is # also recommended to check directory # once after building and before activating the server @@ -65,10 +65,10 @@ # Password to initiate setting up admin account. # It will not be used after the initial setup is complete. # -# Be sure to change this when you set up Misskey via the Internet. +# Be sure to change this when you set up Sharkey via the Internet. # -# The provider of the service who sets up Misskey on behalf of the customer should -# set this value to something unique when generating the Misskey config file, +# The provider of the service who sets up Sharkey on behalf of the customer should +# set this value to something unique when generating the Sharkey config file, # and provide it to the customer. # # setupPassword: example_password_please_change_this_or_you_will_get_hacked @@ -86,11 +86,11 @@ url: https://example.tld/ #───┘ Port and TLS settings └─────────────────────────────────── # -# Misskey requires a reverse proxy to support HTTPS connections. +# Sharkey requires a reverse proxy to support HTTPS connections. # # +----- https://example.tld/ ------------+ # +------+ |+-------------+ +----------------+| -# | User | ---> || Proxy (443) | ---> | Misskey (3000) || +# | User | ---> || Proxy (443) | ---> | Sharkey (3000) || # +------+ |+-------------+ +----------------+| # +---------------------------------------+ # @@ -98,13 +98,13 @@ url: https://example.tld/ # 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. +# The port that your Sharkey server should listen on. port: 3000 # the address to bind to, defaults to "every address" # address: '0.0.0.0' # You can also use UNIX domain socket. -# socket: /path/to/misskey.sock +# socket: /path/to/sharkey.sock # chmodSocket: '777' # ┌──────────────────────────┐ @@ -121,12 +121,21 @@ db: user: sharkey pass: example-misskey-pass - # Whether disable Caching queries - #disableCache: true + ## Log a warning to the server console if any query takes longer than this to complete. + ## Measured in milliseconds; set to 0 to disable. (default: 300) + #slowQueryThreshold: 300 + + # If false, then query results will be cached in redis. + # If true (default), then queries will not be cached. + # This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior. + #disableCache: false # Extra Connection options #extra: # ssl: true + # # Set a higher value if you have timeout issues during migration + # statement_timeout: 10000 + dbReplications: false @@ -284,6 +293,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' @@ -346,6 +360,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy # Reference Implementation: https://github.com/misskey-dev/media-proxy # * Deliver a common cache between instances @@ -358,7 +376,7 @@ proxyRemoteFiles: true # Movie Thumbnail Generation URL # There is no reference implementation. -# For example, Misskey will point to the following URL: +# For example, Sharkey 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 @@ -369,18 +387,36 @@ signToActivityPubGet: true # When using authorized fetch, this is often undesired as any signed activity can be forwarded to a blocked instance by relays and other instances. # This setting allows admins to disable LD signatures for increased privacy, at the expense of fewer relayed activities and additional inbound fetch (GET) requests. attachLdSignatureForRelays: 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' -#] +# Some example configurations: +#allowedPrivateNetworks: +# # Allow connections to 127.0.0.1 on any port +# - '127.0.0.1/32' +# # Allow connections to 127.0.0.* on any port +# - '127.0.0.1/24' +# # Allow connections to 127.0.0.1 on any port +# - '127.0.0.1' +# # Allow connections to 127.0.0.1 on any port +# - network: '127.0.0.1' +# # Allow connections to 127.0.0.1 on port 80 +# - network: '127.0.0.1' +# ports: [80] +# # Allow connections to 127.0.0.1 on port 80 or 443 +# - network: '127.0.0.1' +# ports: +# - 80 +# - 443 #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 @@ -406,6 +442,9 @@ checkActivityPubGetSignature: false # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false +# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable. +# # default: false in production, true otherwise. +# #verbose: false # Settings for the activity logger, which records inbound activities to the database. # Disabled by default due to the large volume of data it saves. @@ -421,3 +460,17 @@ checkActivityPubGetSignature: false # How long to save each log entry before deleting it. # Default: 2592000000 (1 week) #maxAge: 2592000000 + +# Transparently compress every websocket message on clients that support it. +# Trades server CPU usage for reduced bandwidth usage and a faster frontend on the client. +# If you're not using jemalloc, this may cause memory fragmentation and performance issues! (https://www.npmjs.com/package/ws#websocket-compression) +# jemalloc is used by default in the Sharkey Docker image and may be set up manually otherwise: https://github.com/jemalloc/jemalloc/wiki/getting-started +websocketCompression: false + +# Inject arbitrary HTML tags to customize Sharkey without having to fork it +#customHtml: +# head: | +# +# +# +# diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 713c2e5fdd..25d9cfc1fb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,9 @@ "ghcr.io/devcontainers/features/node:1": { "version": "22.11.0" }, - "ghcr.io/devcontainers-contrib/features/corepack:1": {} + "ghcr.io/devcontainers-extra/features/pnpm:2": { + "version": "10.10.0" + } }, "forwardPorts": [3000], "postCreateCommand": "/bin/bash .devcontainer/init.sh", diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 3eb4fc2879..1f8192101b 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -152,6 +152,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' @@ -194,6 +199,10 @@ proxyBypassHosts: #proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 +# Path to the directory that uploaded media will be saved to +# Defaults to a folder called "files" in the Sharkey directory +#mediaDirectory: /var/lib/sharkey + # Media Proxy #mediaProxy: https://example.com/proxy diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index e02a533c15..216292b082 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -7,8 +7,6 @@ sudo apt-get update sudo apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb git config --global --add safe.directory /workspace git submodule update --init -corepack install -corepack enable pnpm config set store-dir /home/node/.local/share/pnpm/store pnpm install --frozen-lockfile cp .devcontainer/devcontainer.yml .config/default.yml diff --git a/.gitignore b/.gitignore index 333caec619..bc248c3aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,7 @@ vite.config.local-dev.ts.timestamp-* # dev env /compose.yml + +# TypeScript +.tsbuildinfo +*.tsbuildinfo diff --git a/.npmrc b/.npmrc index b949431c77..d007b27572 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,4 @@ @transfem-org:registry=https://activitypub.software/api/v4/packages/npm/ engine-strict = true +save-exact = true +shell-emulator = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d36875eb4..87315a675a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,198 @@ +## 2025.4.1 + +### General +- Feat: bull-boardに代わるジョブキューの管理ツールが実装されました +- Feat: アップロード可能な最大ファイルサイズをロールごとに設定可能に + - デフォルトで10MBになっています +- Enhance: チャットの新規メッセージをプッシュ通知するように +- Enhance: サーバーブロックの対象になっているサーバーについて、当該サーバーのユーザーや既知投稿を見えないように +- Enhance: 依存関係の更新 +- Enhance: 翻訳の更新 +- Fix: セキュリティに関する修正 + +### Client +- Feat: チャットウィジェットを追加 +- Feat: デッキにチャットカラムを追加 +- Feat: タイトルバーを表示できるように +- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように +- Enhance: コントロールパネルでジョブキューをクリアできるように +- Enhance: テーマでページヘッダーの色を変更できるように +- Enhance: スワイプでのタブ切り替えを強化 +- Enhance: デザインのブラッシュアップ +- Fix: ログアウトした際に処理が終了しない問題を修正 +- Fix: 自動バックアップが設定されている環境でログアウト直前に設定をバックアップするように +- Fix: フォルダを開いた状態でメニューからアップロードしてもルートフォルダにアップロードされる問題を修正 #15836 +- Fix: タイムラインのスクロール位置を記憶するように修正 +- Fix: ノートの直後のノートを表示する機能で表示が逆順になっていた問題を修正 #15841 +- Fix: アカウントの移行時にアンテナのフィルターのユーザが更新されない問題を修正 #15843 +- Fix: タイムラインでノートが重複して表示されることがあるのを修正 + +### Server +- Enhance: ジョブキューの成功/失敗したジョブも一定数・一定期間保存するようにし、後から問題を調査することを容易に +- Enhance: フォローしているユーザーならフォロワー限定投稿のノートでもアンテナで検知できるように + (Cherry-picked from https://github.com/yojo-art/cherrypick/pull/568 and https://github.com/team-shahu/misskey/pull/38) +- Enhance: ユーザーごとにノートの表示が高速化するように +- Fix: システムアカウントの名前がサーバー名と同期されない問題を修正 +- Fix: 大文字を含むユーザの URL で照会された場合に 404 エラーを返す問題 #15813 +- Fix: リードレプリカ設定時にレコードの追加・更新・削除を伴うクエリを発行した際はmasterノードで実行されるように調整( #10897 ) +- Fix: ファイルアップロード時の挙動を一部調整(#15895) + +## 2025.4.0 + +### General +- Feat: チャット(ダイレクトメッセージ)がリニューアルして復活しました + - 既存のDM機能よりも便利で効率的な実装になっています + - チャットを受け付ける相手を制限可能です + - 誰でも / フォローユーザーのみ / フォロワーのみ / 相互のみ / 受け付けない から選択できます + - 自分からメッセージを送った相手とは上記の設定に関わらずチャット可能です + - チャット機能を開放するかどうかをロールで制御可能です + - ルームを作成して、複数人でのチャットも可能です + - 過去自分が送ったメッセージ・自分に送られたメッセージの検索が可能です + - 参加中のルームをミュートして通知が来ないように設定可能です + - メッセージにはリアクションも可能です + - 現在、リモートユーザーがチャットを受け付ける設定になっているかどうかを取得する術がないため、ローカルユーザー間でのみ利用可能です +- Feat: アカウントの移行時に古いアカウントからあたらしいアカウントにロールをコピーできるようになりました。 + - 管理者がロールの設定でマイグレーション時にコピーするかを指定できるようになります。 +- Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。 + - Misskeyネイティブでダッシュボードを実装予定です +- Enhance: フロントエンドのエラートラッキングができるように + - `.config/default.yml`中の項目`sentryForFrontend`を適宜設定してください。 + - 外部サービスであるSentryへエラー情報が送信されます。ご利用の地域の法令に従い、適切なプライバシーポリシーを策定の上で運用してください。 +- Enhance: ミュートしているユーザーをユーザー検索の結果から除外するように +- Enhance: アンテナでセンシティブなチャンネルのノートを除外できるように `#14177` +- Fix: 通知のページネーションで2つ以上読み込めなくなることがある問題を修正 + +### Client +- Feat: 設定の管理が強化されました + - 内部処理が一新され、安定性とパフォーマンスが向上しました + - 全てのクライアント設定がエクスポート(バックアップ)/インポート対象に含まれるようになりました + - プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました + - 自動で設定データをサーバーにバックアップできるように + - 設定→設定のプロファイル→自動バックアップ で有効にできます + - ログインしたとき、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能) + - 任意の設定項目をデバイス間で同期できるように + - 設定項目の「...」メニュー→「デバイス間で同期」 + - 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます + - 任意の設定項目を初期値にリセットできるように + - 設定項目の「...」メニュー→「初期値にリセット」 + - アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように + - 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます + - ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました + - バックアップを有効にしている場合、ログインした後にバックアップから設定データを復元可能です + - エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました + - 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です + - 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください +- Feat: 画面を重ねて表示するオプションを実装(実験的) + - 設定 → その他 → 実験的機能 → Enable stacking router view +- Enhance: プラグインの管理が強化されました + - インストール/アンインストール/設定の変更時にリロード不要になりました +- Enhance: ログアウト時、ブラウザに保存されたWebクライアントのデータを全て消去するように +- Enhance: デッキUIでカラム間のマージンを設定できるように +- Enhance: デッキUIでデッキメニューの位置を設定できるように +- Enhance: デッキUIでナビゲーションバーの位置を設定できるように +- Enhance: アイコンのスクロール追従を無効化してパフォーマンス向上できるように +- Enhance: CWの注釈テキストが入力されていない場合, Postボタンを非アクティブに +- Enhance: CWを無効にした場合, 注釈テキストが最大入力文字数を超えていても投稿できるように +- Enhance: テーマ設定画面のデザインを改善 +- Enhance: 投稿フォームの設定メニューを改良 + - 投稿フォームをリセットできるように + - 文字数カウントを復活 +- Enhance: 2段階認証時のリカバリーコードのファイル名にサーバーURLを含めるように +- Enhance: 全体的なブラッシュアップ +- Enhance 全体的なパフォーマンス向上 +- Enhance: ファイルのアップロードでデフォルトで圧縮するかどうかのオプションが廃止され、アップロード時に圧縮するかどうかを選択するようになりました + - 画像データの貼り付け、ドロップ時は圧縮されるようになりました +- Fix: 読み込み直後にスクロールしようとすると途中で止まる場合があるのを修正 +- Fix: テーマ切り替え時に一部の色が変わらない問題を修正 +- Fix: iPadOSでdeck uiをマウスカーソルによってスクロールできない問題を修正 +- NOTE: 構造上クラシックUIを新しいデザインシステムに移行することが困難なため、クラシックUIが削除されました + - デッキUIでカラムを中央寄せにし、メインカラムの左右にウィジェットカラムを配置し、ナビゲーションバーを上部に表示することである程度クラシックUIを再現できます + +### Server +- Enhance 全体的なパフォーマンス向上 +- Fix: プロフィール追加情報で無効なURLに入力された場合に照会エラーを出るのを修正 +- Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正 +- Fix: 連合無しモードでも外部から照会可能だった問題を修正 +- Fix: テスト用WebHookのペイロードの`emojis`パラメータが実際のものと異なる問題を修正 +- Fix: 非ログインでタイムラインのストリームに接続した際、表示にログイン必須のノートが流れる場合がある問題を修正 + +## 2025.3.1 + +### General +- pnpmをv10に更新 +- Corepackを削除 + +### Client +- Feat: 設定の検索を追加(実験的) +- Enhance: 設定項目の再配置 + +### Server +- Fix: DBマイグレーション際にシステムアカウントのユーザーID判定が正しくない問題を修正 +- Fix: user.featured列が状況によってJSON文字列になっていたのを修正 + + +## 2025.3.0 + +### General +- Enhance: プロキシアカウントをシステムアカウントとして作成するように +- Enhance: OAuthで外部アプリからロゴが提供されている場合、それを表示できるように + 書式は https://indieauth.spec.indieweb.org/20220212/#example-2 に準じます。 +- Fix: システムアカウントが削除できる問題を修正 + +### Client +- Enhance: モデレーターがセンシティブ設定を変更する際に確認ダイアログを出すように +- Enhance: 「UIのアニメーションを減らす」で画面上のエフェクトも減らせるように +- Enhance: 投稿フォームにおける、メディアの添付可能個数のカウントを反転しました + - これまでの表示は`添付可能残り個数/上限数`でしたが、`添付個数/上限数`としました +- Fix: フォローされたときのメッセージがちらつくことがある問題を修正 +- Fix: 投稿ダイアログがサイズ限界を超えた際にスクロールできない問題を修正 + +### Server +- Fix: 特定のケースでActivityPubの処理がデッドロックになることがあるのを修正 +- Fix: S3互換オブジェクトストレージでファイルのアップロードに失敗することがある問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/895) + + +## 2025.2.1 + +### General +- Feat: アクセストークン発行時に通知するように +- 依存関係の更新 + +### Client +- Feat: 投稿フォームで画像をプレビュー可能に +- Enhance: 投稿フォームの「迷惑になる可能性があります」のダイアログを表示する条件においてCWを考慮するように +- Enhance: アンテナ、リスト等の名前をカラム名のデフォルト値にするように `#13992` +- Enhance: クライアントエラー画面の多言語対応 +- Enhance: 開発者モードでメニューからファイルIDをコピー出来るように `#15441' +- Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 ) +- Enhance: リアクションする際に確認ダイアログを表示できるように +- Enhance: コントロールパネルのユーザ検索で入力された情報をページ遷移で損なわないように `#15437` +- Enhance: CWの注釈で入力済みの文字数を表示 +- Enhance: ノート検索ページのデザイン調整 + (Cherry-picked from https://github.com/taiyme/misskey/pull/273) +- Fix: ノートページで、クリップ一覧が表示されないことがある問題を修正 +- Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529` +- Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正 +- Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378` +- Fix: カスタム絵文字管理画面(beta)にてisSensitive/localOnlyの絞り込みが上手くいかない問題の修正 ( #15445 ) +- Fix: ユーザのサジェスト中に@を入力してもサジェスト結果が消えないように `#14385` +- Fix: CWの注釈が100文字を超えている場合、ノート投稿ボタンを非アクティブに +- Fix: テーマ選択で現在のテーマが初期表示されていない問題を修正 +- 翻訳の更新 + +### Server +- Enhance: 成り済まし対策として、ActivityPub照会された時にリモートのリダイレクトを拒否できるように (config.disallowExternalApRedirect) +- Fix: `following/invalidate`でフォロワーを解除しようとしているユーザーの情報を返すように +- Fix: オブジェクトストレージの設定でPrefixを設定していなかった場合nullまたは空文字になる問題を修正 +- Fix: HTTPプロキシとその除外設定を行った状態でカスタム絵文字の一括インポートをしたとき、除外設定が効かないのを修正( #8766 ) +- Fix: pgroongaでの検索時にはじめのキーワードのみが検索に使用される問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/886) +- Fix: メールアドレスの形式が正しくなければ以降の処理を行わないように +- Fix: `update-meta`でobjectStoragePrefixにS3_SAFEかつURL-safeでない文字列を使えないように +- Fix: クリップの説明欄を更新する際に空にできない問題を修正 +- Fix: フォロワーではないユーザーにリノートもしくは返信された場合にノートのDeleteアクティビティが送られていない問題を修正 + ## 2025.2.0 ### General diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76f9d7b613..e1a12926cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -281,7 +281,6 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド query?: Record; loginRequired?: boolean; hash?: string; - globalCacheKey?: string; children?: RouteDef[]; } ``` @@ -623,6 +622,35 @@ marginはそのコンポーネントを使う側が設定する ### indexというファイル名を使うな ESMではディレクトリインポートは廃止されているのと、ディレクトリインポートせずともファイル名が index だと何故か一部のライブラリ?でディレクトリインポートだと見做されてエラーになる +### Memory Caches + +Sharkey offers multiple memory cache implementations, each meant for a different use case. +The following table compares the available options: + +| Cache | Type | Consistency | Persistence | Data Source | Cardinality | Eviction | Description | +|---------------------|-----------|-------------|-------------|-------------|-------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `MemoryKVCache` | Key-Value | None | None | Caller | Single | Lifetime | Implements a basic in-memory Key-Value store. The implementation is entirely synchronous, except for user-provided data sources. | +| `MemorySingleCache` | Single | None | None | Caller | Single | Lifetime | Implements a basic in-memory Single Value store. The implementation is entirely synchronous, except for user-provided data sources. | +| `RedisKVCache` | Key-Value | Eventual | Redis | Callback | Single | Lifetime | Extends `MemoryKVCache` with Redis-backed persistence and a pre-defined callback data source. This provides eventual consistency guarantees based on the memory cache lifetime. | +| `RedisSingleCache` | Single | Eventual | Redis | Callback | Single | Lifetime | Extends `MemorySingleCache` with Redis-backed persistence and a pre-defined callback data source. This provides eventual consistency guarantees based on the memory cache lifetime. | +| `QuantumKVCache` | Key-Value | Immediate | None | Callback | Multiple | Lifetime | Combines `MemoryKVCache` with a pre-defined callback data source and immediate consistency via Redis sync events. The implementation offers multi-item batch overloads for efficient bulk operations. **This is the recommended cache implementation for most use cases.** | + +Key-Value caches store multiple entries per cache, while Single caches store a single value that can be accessed directly. +Consistency refers to the consistency of cached data between different processes in the instance cluster: "None" means no consistency guarantees, "Eventual" caches will gradually become consistent after some unknown time, and "Immediate" consistency ensures accurate data ASAP after the update. +Caches with persistence can retain their data after a reboot through an external service such as Redis. +If a data source is supported, then this allows the cache to directly load missing data in response to a fetch. +"Caller" data sources are passed into the fetch method(s) directly, while "Callback" sources are passed in as a function when the cache is first initialized. +The cardinality of a cache refers to the number of items that can be updated in a single operation, and eviction, finally, is the method that the cache uses to evict stale data. + +#### Selecting a cache implementation + +For most cache uses, `QuantumKVCache` should be considered first. +It offers strong consistency guarantees, multiple cardinality, and a cleaner API surface than the older caches. +An alternate cache implementation should be considered if any of the following apply: +* The data is particularly slow to calculate or difficult to access. In these cases, either `RedisKVCache` or `RedisSingleCache` should be considered. +* If stale data is acceptable, then consider `MemoryKVCache` or `MemorySingleCache`. These synchronous implementations have much less overhead than the other options. +* There is only one data item, or all data items must be fetched together. Using `MemorySingleCache` or `RedisSingleCache` could provide a cleaner implementation without resorting to hacks like a fixed key. + ## CSS Recipe ### Lighten CSS vars @@ -660,54 +688,44 @@ seems to do a decent job) *after that commit*, do all the extra work, on the same branch: * copy all changes (commit after each step): - * in - `packages/backend/src/core/activitypub/models/ApNoteService.ts`, - from `createNote` to `updateNote` - * from `packages/backend/src/core/NoteCreateService.ts` to - `packages/backend/src/core/NoteEditService.ts` - * from `packages/backend/src/server/api/endpoints/notes/create.ts` - to `packages/backend/src/server/api/endpoints/notes/edit.ts` - * from `packages/frontend/src/components/MkNote*.vue` to - `packages/frontend/src/components/SkNote*.vue` (if sensible) + * in `packages/backend/src/core/activitypub/models/ApNoteService.ts`, from `createNote` to `updateNote` + * from `packages/backend/src/core/NoteCreateService.ts` to `packages/backend/src/core/NoteEditService.ts` + * from `packages/backend/src/server/api/endpoints/notes/create.ts` to `packages/backend/src/server/api/endpoints/notes/edit.ts` + * from MK note components to SK note components (if sensible) + * from `packages/frontend/src/components/MkNote.vue` to `packages/frontend/src/components/SkNote.vue` + * from `packages/frontend/src/components/MkNoteDetailed.vue` to `packages/frontend/src/components/SkNoteDetailed.vue` + * from `packages/frontend/src/components/MkNoteHeader.vue` to `packages/frontend/src/components/SkNoteHeader.vue` + * from `packages/frontend/src/components/MkNoteSimple.vue` to `packages/frontend/src/components/SkNoteSimple.vue` + * from `packages/frontend/src/components/MkNoteSub.vue` to `packages/frontend/src/components/SkNoteSub.vue` + * from MK note components to Dynamic note components (if the public signature changed) + * from `packages/frontend/src/components/MkNote.vue` to `packages/frontend/src/components/DynamicNote.vue` + * from `packages/frontend/src/components/MkNoteDetailed.vue` to `packages/frontend/src/components/DynamicNoteDetailed.vue` + * from `packages/frontend/src/components/MkNoteSimple.vue` to `packages/frontend/src/components/DynamicNoteSimple.vue` * from the global timeline to the bubble timeline - (`packages/backend/src/server/api/stream/channels/global-timeline.ts`, - `packages/backend/src/server/api/stream/channels/bubble-timeline.ts`, - `packages/frontend/src/timelines.ts`, - `packages/frontend/src/components/MkTimeline.vue`, - `packages/frontend/src/pages/timeline.vue`, - `packages/frontend/src/ui/deck/tl-column.vue`, - `packages/frontend/src/widgets/WidgetTimeline.vue`) -* if there have been any changes to the federated user data (the - `renderPerson` function in - `packages/backend/src/core/activitypub/ApRendererService.ts`), make - sure that the set of fields in `userNeedsPublishing` and - `profileNeedsPublishing` in - `packages/backend/src/server/api/endpoints/i/update.ts` are still - correct -* check the changes against our `develop` (`git diff develop`) and - against Misskey (`git diff misskey/develop`) + * `packages/backend/src/server/api/stream/channels/global-timeline.ts` + * `packages/backend/src/server/api/stream/channels/bubble-timeline.ts` + * `packages/frontend/src/timelines.ts` + * `packages/frontend/src/components/MkTimeline.vue` + * `packages/frontend/src/pages/timeline.vue` + * `packages/frontend/src/ui/deck/tl-column.vue` + * `packages/frontend/src/widgets/WidgetTimeline.vue` + * from `packages/backend/src/queue/processors/InboxProcessorService.ts` to `packages/backend/src/core/UpdateInstanceQueue.ts`, where `updateInstanceQueue` is impacted + * from `.config/example.yml` to `.config/ci.yml` and `chart/files/default.yml` + * in `packages/backend/src/core/MfmService.ts`, from `toHtml` to `toMastoApiHtml` + * from `verifyLink` in `packages/backend/src/core/activitypub/models/ApPersonService.ts` to `verifyFieldLinks` in `packages/backend/src/misc/verify-field-link.ts` (if sensible) +* if there have been any changes to the federated user data (the `renderPerson` function in `packages/backend/src/core/activitypub/ApRendererService.ts`), make sure that the set of fields in `userNeedsPublishing` and `profileNeedsPublishing` in `packages/backend/src/server/api/endpoints/i/update.ts` are still correct. +* check the changes against our `develop` (`git diff develop`) and against Misskey (`git diff misskey/develop`) * re-generate `misskey-js` (`pnpm build-misskey-js-with-types`) and commit -* build the frontend: `rm -rf built/; NODE_ENV=development pnpm - --filter=frontend --filter=frontend-embed --filter=frontend-shared - build` (the `development` tells it to keep some of the original - filenames in the built files) -* make sure there aren't any new `ti-*` classes (Tabler Icons), and - replace them with appropriate `ph-*` ones (Phosphor Icons) in - [`vite.replaceicons.ts`](packages/frontend/vite.replaceIcons.ts). - This command should show you want to change: `grep -ohrP - '(?<=["'\'']ti )(ti-(?!fw)[\w\-]+)' --exclude \*.map -- built/ | - sort -u`. +* re-generate locales (`pnpm run build-assets`) and commit +* build the frontend: `rm -rf built/; NODE_ENV=development pnpm --filter=frontend --filter=frontend-embed --filter=frontend-shared build` (the `development` tells it to keep some of the original filenames in the built files) +* make sure there aren't any new `ti-*` classes (Tabler Icons), and replace them with appropriate `ph-*` ones (Phosphor Icons) in [`vite.replaceicons.ts`](packages/frontend/vite.replaceIcons.ts). + * This command should show you want to change: `grep -ohrP '(?<=["'\''](ti )?)(ti-(?!fw)[\w\-]+)' --exclude \*.map -- built/ | sort -u`. * NOTE: `ti-fw` is a special class that's defined by Misskey, leave it alone. - * After every change, re-build the frontend and check again, until - there are no more `ti-*` classes in the built files. + * After every change, re-build the frontend and check again, until there are no more `ti-*` classes in the built files. * Commit! -* double-check the new migration, that they won't conflict with our db - changes: `git diff develop -- packages/backend/migration/` +* double-check the new migration, that they won't conflict with our db changes: `git diff develop -- packages/backend/migration/` * `pnpm clean; pnpm build` -* run tests `pnpm test; pnpm --filter backend test:e2e` (requires a - test database, [see above](#testing)) and fix as much as you can -* run lint `pnpm --filter=backend --filter=frontend-shared lint` + - `pnpm --filter=frontend --filter=frontend-embed eslint` and fix as - much as you can +* run tests `pnpm test; pnpm --filter backend test:e2e` (requires a test database, [see above](#testing)) and fix as much as you can. +* run lint `pnpm --filter=backend --filter=frontend-shared lint` + `pnpm --filter=frontend --filter=frontend-embed eslint` and fix as much as you can. Then push and open a Merge Request. diff --git a/Dockerfile b/Dockerfile index 72f934b3ce..e6e60992e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ ARG UID="991" ARG GID="991" ENV COREPACK_DEFAULT_TO_LATEST=0 -RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng \ +RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng librsvg font-noto font-noto-cjk font-noto-thai \ && corepack enable \ && addgroup -g "${GID}" sharkey \ && adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \ diff --git a/README.md b/README.md index f9198c06c0..8f1a851b68 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@
- + ## ✨ Features - **ActivityPub support**\ diff --git a/UPGRADE_NOTES.md b/UPGRADE_NOTES.md index c941de6643..47ac649c31 100644 --- a/UPGRADE_NOTES.md +++ b/UPGRADE_NOTES.md @@ -1,5 +1,14 @@ # Upgrade Notes +## 2025.X.X + +### Authorized Fetch + +This version retires the configuration entry `checkActivityPubGetSignature`, which is now replaced with the new "Authorized Fetch" settings under Control Panel/Security. +The database migrations will automatically import the value of this configuration file, but it will never be read again after upgrading. +To avoid confusion and possible mis-configuration, please remove the entry **after** completing the upgrade. +Do not remove it before migration, or else the setting will reset to default (disabled)! + ## 2024.10.0 ### Hellspawns diff --git a/assets/about/drive.png b/assets/about/drive.png deleted file mode 100644 index 16037aae39..0000000000 Binary files a/assets/about/drive.png and /dev/null differ diff --git a/assets/about/post.png b/assets/about/post.png deleted file mode 100644 index 3c55f66c56..0000000000 Binary files a/assets/about/post.png and /dev/null differ diff --git a/assets/about/reaction.png b/assets/about/reaction.png deleted file mode 100644 index e4e7e06bc0..0000000000 Binary files a/assets/about/reaction.png and /dev/null differ diff --git a/assets/about/ui.png b/assets/about/ui.png deleted file mode 100644 index 0601837f4c..0000000000 Binary files a/assets/about/ui.png and /dev/null differ diff --git a/assets/sharkey.webp b/assets/sharkey.webp new file mode 100644 index 0000000000..2ea900068c Binary files /dev/null and b/assets/sharkey.webp differ diff --git a/assets/ss/explore.jpg b/assets/ss/explore.jpg deleted file mode 100644 index bf81d794c3..0000000000 Binary files a/assets/ss/explore.jpg and /dev/null differ diff --git a/assets/ss/user.jpg b/assets/ss/user.jpg deleted file mode 100644 index 3ec595c199..0000000000 Binary files a/assets/ss/user.jpg and /dev/null differ diff --git a/chart/files/default.yml b/chart/files/default.yml index 4f43d52ae0..6fb8b3eed5 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -1,7 +1,78 @@ #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Misskey configuration +# Sharkey configuration #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# ┌──────────────────────────────┐ +#───┘ a boring but important thing └──────────────────────────── + +# +# First of all, let me tell you a story that may possibly be +# boring to you and possibly important to you. +# +# Sharkey is licensed under the AGPLv3 license. This license is +# known to be often misunderstood. Please read the following +# instructions carefully and select the appropriate option so +# that you do not negligently cause a license violation. +# + +# -------- +# Option 1: If you host Sharkey AS-IS (without any changes to +# the source code. forks are not included). +# +# Step 1: Congratulations! You don't need to do anything. + +# -------- +# Option 2: If you have made changes to the source code (forks +# are included) and publish a Git repository of source +# code. There should be no access restrictions on +# this repository. Strictly speaking, it doesn't have +# to be a Git repository, but you'll probably use Git! +# +# Step 1: Build and run the Sharkey server first. +# Step 2: Open in +# your browser with the administrator account. +# Step 3: Enter the URL of your Git repository in the +# "Repository URL" field. + +# -------- +# Option 3: If neither of the above applies to you. +# (In this case, the source code should be published +# on the Sharkey interface. IT IS NOT ENOUGH TO +# DISCLOSE THE SOURCE CODE WHEN A USER REQUESTS IT BY +# E-MAIL OR OTHER MEANS. If you are not satisfied +# with this, it is recommended that you read the +# license again carefully. Anyway, enabling this +# option will automatically generate and publish a +# tarball at build time, protecting you from +# inadvertent license violations. (There is no legal +# guarantee, of course.) The tarball will generated +# from the root directory of your codebase. So it is +# also recommended to check directory +# once after building and before activating the server +# to avoid ACCIDENTAL LEAKING OF SENSITIVE INFORMATION. +# To prevent certain files from being included in the +# tarball, add a glob pattern after line 15 in +# . DO NOT FORGET TO BUILD AFTER +# ENABLING THIS OPTION!) +# +# Step 1: Uncomment the following line. +# +# publishTarballInsteadOfProvideRepositoryUrl: true + +# ┌────────────────────────┐ +#───┘ Initial Setup Password └───────────────────────────────────────────────────── + +# Password to initiate setting up admin account. +# It will not be used after the initial setup is complete. +# +# Be sure to change this when you set up Sharkey via the Internet. +# +# The provider of the service who sets up Sharkey on behalf of the customer should +# set this value to something unique when generating the Sharkey config file, +# and provide it to the customer. +# +# setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── @@ -15,14 +86,14 @@ #───┘ Port and TLS settings └─────────────────────────────────── # -# Misskey supports two deployment options for public. +# Sharkey supports two deployment options for public. # # Option 1: With Reverse Proxy # # +----- https://example.tld/ ------------+ # +------+ |+-------------+ +----------------+| -# | User | ---> || Proxy (443) | ---> | Misskey (3000) || +# | User | ---> || Proxy (443) | ---> | Sharkey (3000) || # +------+ |+-------------+ +----------------+| # +---------------------------------------+ # @@ -33,15 +104,15 @@ # # +- https://example.tld/ -+ # +------+ | +---------------+ | -# | User | ---> | | Misskey (443) | | +# | User | ---> | | Sharkey (443) | | # +------+ | +---------------+ | # +------------------------+ # -# You need to run Misskey as root. +# You need to run Sharkey as root. # You need to set Certificate in 'https' section. # To use option 1, uncomment below line. -port: 3000 # A port that your Misskey server should listen. +port: 3000 # A port that your Sharkey server should listen. # To use option 2, uncomment below lines. #port: 443 @@ -173,6 +244,11 @@ id: "aidx" # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' @@ -245,6 +321,11 @@ checkActivityPubGetSignature: false #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index d2efbf709c..6471f96504 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -233,7 +233,7 @@ describe('After user setup', () => { cy.get('[data-cy-post-form-text]').type('Hello, Misskey!'); cy.get('[data-cy-open-post-form-submit]').click(); - cy.contains('Hello, Misskey!'); + cy.contains('Hello, Misskey!', { timeout: 15000 }); }); it('open note form with hotkey', () => { diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 6fe7f32cc4..4435a4fda8 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "lib": ["dom", "es5"], "target": "es5", - "types": ["cypress", "node"] + "types": ["cypress", "node"], + "incremental": true }, "include": ["./**/*.ts"] } diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 91c90ce75a..adc37c2414 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -251,7 +251,6 @@ removeAreYouSure: "متأكد من أنك تريد حذف {x}؟" deleteAreYouSure: "متأكد من أنك تريد حذف {x}؟" resetAreYouSure: "هل تريد إعادة التعيين؟" saved: "حُفظ" -messaging: "المحادثة" upload: "ارفع" keepOriginalUploading: "ابق الصورة الأصلية" keepOriginalUploadingDescription: "يحفظ الصور المرفوعة على حالتها الأصلية، وان عطّل ستولد نسخة مخصصة من الصورة." @@ -264,7 +263,6 @@ uploadFromUrlMayTakeTime: "سيستغرق بعض الوقت لاتمام الر explore: "استكشاف" messageRead: "مقروءة" noMoreHistory: "لا يوجد المزيد من التاريخ" -startMessaging: "ابدأ محادثة" nUsersRead: "قرأه {n}" agreeTo: "اوافق على {0}" agree: "أقبل" @@ -436,8 +434,6 @@ retype: "أعد الكتابة" noteOf: "ملاحظات {user}" quoteAttached: "اِقتُبسَ" quoteQuestion: "أتريد تضمينها كاقتباس" -noMessagesYet: "ليس هناك رسائل بعد" -newMessageExists: "لقد تلقيت رسالة جديدة" onlyOneFileCanBeAttached: "يمكنك إرفاق ملف واحد بالرسالة" signinRequired: "رجاءً لِج" invitations: "دعوة" @@ -1012,6 +1008,14 @@ sourceCode: "الشفرة المصدرية" flip: "اقلب" lastNDays: "آخر {n} أيام" surrender: "ألغِ" +postForm: "أنشئ ملاحظة" +information: "عن" +_chat: + invitations: "دعوة" + noHistory: "السجل فارغ" + members: "الأعضاء" + home: "الرئيسي" + send: "أرسل" _delivery: stop: "مُعلّق" _initialAccountSetting: @@ -1236,7 +1240,6 @@ _theme: shadow: "الظل" navBg: "خلفية الشريط الجانبي" navFg: "نص الشريط الجانبي" - navHoverFg: "نص الشريط الجانبي (عند التمرير فوقه)" link: "رابط" hashtag: "وسم" mention: "أشر الى" @@ -1311,6 +1314,7 @@ _permissions: "read:gallery": "اعرض المعرض" "write:gallery": "عدّل المعرض" "read:gallery-likes": "يعرض ما أعجبك من مشاركات المعرض" + "write:chat": "اكتب أو احذف رسائل محادثة" _auth: shareAccess: "أتريد التفويض لـ \"{name}\" بالوصول لحسابك؟" shareAccessAsk: "هل تخول لهذا التطبيق الوصول لحسابك؟" @@ -1584,3 +1588,7 @@ _offlineScreen: _remoteLookupErrors: _noSuchObject: title: "غير موجود" +_search: + searchScopeAll: "الكل" + searchScopeLocal: "المحلي" + searchScopeUser: "مستخدم محدد" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 709874ac20..442fed6e21 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -252,7 +252,6 @@ removeAreYouSure: "আপনি কি \"{x}\" সরানোর ব্যা deleteAreYouSure: "আপনি কি \"{x}\" সরানোর ব্যাপারে নিশ্চিত?" resetAreYouSure: "রিসেট করার ব্যাপারে নিশ্চিত?" saved: "সংরক্ষিত হয়েছে" -messaging: "চ্যাট" upload: "আপলোড" keepOriginalUploading: "আসল ছবি রাখুন" keepOriginalUploadingDescription: "ছবিটি আপলোড করার সময় আসল সংস্করণটি রাখুন। অপশনটি বন্ধ থাকলে, আপলোডের সময় ওয়েব প্রকাশনার জন্য ছবি ব্রাউজারে তৈরি করা হবে।" @@ -265,7 +264,6 @@ uploadFromUrlMayTakeTime: "URL হতে আপলোড হতে কিছু explore: "ঘুরে দেখুন" messageRead: "পড়া" noMoreHistory: "আর কোন ইতিহাস নেই" -startMessaging: "চ্যাট শুরু করুন" nUsersRead: "{n} জন পড়েছেন" agreeTo: "{0} এর প্রতি আমি সম্মত" start: "শুরু করুন" @@ -427,8 +425,6 @@ retype: "পুনঃ প্রবেশ" noteOf: "{user} এর নোট" quoteAttached: "উদ্ধৃত" quoteQuestion: "উদ্ধৃতি হিসাবে সংযুক্ত করবেন?" -noMessagesYet: "কোন মেসেজ নেই" -newMessageExists: "নতুন মেসেজ পেয়েছেন" onlyOneFileCanBeAttached: "আপনি মেসেজের সাথে সর্বোচ্চ একটি ফাইল যুক্ত করতে পারবেন" signinRequired: "দয়া করে লগ ইন করুন" invitations: "আমন্ত্রণ" @@ -852,6 +848,14 @@ replies: "জবাব" renotes: "রিনোট" sourceCode: "সোর্স কোড" flip: "উল্টান" +postForm: "নোট লিখুন" +information: "আপনার সম্পর্কে" +_chat: + invitations: "আমন্ত্রণ" + noHistory: "কোনো ইতিহাস নেই" + members: "সদস্যবৃন্দ" + home: "মূল পাতা" + send: "পাঠান" _delivery: stop: "স্থগিত করা হয়েছে" _type: @@ -994,7 +998,6 @@ _theme: header: "হেডার" navBg: "সাইডবারের পটভূমি" navFg: "সাইডবারের পাঠ্য" - navHoverFg: "সাইডবারের পাঠ্য (হভার)" navActive: "সাইডবারের পাঠ্য (অ্যাকটিভ)" navIndicator: "সাইডবারের ইনডিকেটর" link: "লিংক" @@ -1017,11 +1020,8 @@ _theme: buttonHoverBg: "বাটনের পটভূমি (হভার)" inputBorder: "ইনপুট ফিল্ডের বর্ডার" driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি" - wallpaperOverlay: "ওয়ালপেপার ওভারলে" badge: "ব্যাজ" messageBg: "চ্যাটের পটভূমি" - accentDarken: "অ্যাকসেন্ট (গাঢ়)" - accentLighten: "অ্যাকসেন্ট (হাল্কা)" fgHighlighted: "হাইলাইট করা পাঠ্য" _sfx: note: "নোটগুলি" @@ -1084,6 +1084,7 @@ _permissions: "write:gallery": "গ্যালারী সম্পাদনা করুন" "read:gallery-likes": "গ্যালারীর পছন্দগুলি দেখুন" "write:gallery-likes": "গ্যালারীর পছন্দগুলি সম্পাদনা করুন" + "write:chat": "চ্যাটগুলি সম্পাদনা করুন" _auth: shareAccess: "\"{name}\" কে অ্যাকাউন্টের অ্যাক্সেস দিবেন?" shareAccessAsk: "অ্যাপ্লিকেশনটিকে অ্যাকাউন্টের অ্যাক্সেস দিবেন?" @@ -1348,3 +1349,6 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "পাওয়া যায়নি" +_search: + searchScopeAll: "সবগুলো" + searchScopeLocal: "স্থানীয়" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 7b029c6f41..23d20b8e13 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -66,7 +66,7 @@ copyFolderId: "Copiar ID de la carpeta" copyProfileUrl: "Copiar adreça URL del perfil" searchUser: "Cercar un usuari" searchThisUsersNotes: "Cercar les publicacions de l'usuari" -reply: "Respon" +reply: "Respostes" loadMore: "Carregar més" showMore: "Veure més" showLess: "Mostrar menys" @@ -111,7 +111,7 @@ followRequests: "Peticions de seguiment" unfollow: "Deixar de seguir" followRequestPending: "Sol·licituds de seguiment pendents" enterEmoji: "Introduir un emoji" -renote: "Impulsar " +renote: "Impulsar" unrenote: "Anul·la l'impuls" renoted: "S'ha impulsat" renotedToX: "Impulsat per {name}." @@ -260,7 +260,7 @@ noCustomEmojis: "No hi ha emojis personalitzats" noJobs: "No hi ha feines" federating: "Federant" blocked: "Bloquejat" -suspended: "Suspés" +suspended: "Anul·lar subscripció " all: "tot" subscribing: "Subscrit a" publishing: "S'està publicant" @@ -280,7 +280,7 @@ featured: "Destacat" usernameOrUserId: "Nom o ID d'usuari" noSuchUser: "No s'ha trobat l'usuari" lookup: "Cerca" -announcements: "Anuncis" +announcements: "Avisos" imageUrl: "URL de la imatge" remove: "Eliminar" removed: "Eliminat" @@ -289,7 +289,6 @@ deleteAreYouSure: "Segur que vols esborrar «{x}»?" resetAreYouSure: "Segur que vols restablir-ho?" areYouSure: "Estàs segur?" saved: "S'ha desat" -messaging: "Xat" upload: "Puja" keepOriginalUploading: "Guarda la imatge original" keepOriginalUploadingDescription: "Guarda la imatge pujada sense modificar. Si està desactivat, es generarà una versió per visualitzar a la web en pujar la imatge." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "La càrrega des de l'enllaç pot trigar un temps" explore: "Explora" messageRead: "Vist" noMoreHistory: "No hi ha res més per veure" -startMessaging: "Comença a xatejar" +startChat: "Comença a xatejar " nUsersRead: "Vist per {n}" agreeTo: "Accepto que {0}" agree: "Hi estic d'acord" @@ -357,7 +356,7 @@ banner: "Bàner" displayOfSensitiveMedia: "Visualització de contingut sensible" whenServerDisconnected: "Quan es perdi la connexió al servidor" disconnectedFromServer: "Desconnectat pel servidor" -reload: "Actualitza" +reload: "Actualitzar" doNothing: "Ignora" reloadConfirm: "Vols recarregar?" watch: "Veure" @@ -425,6 +424,7 @@ antennaExcludeBots: "Exclou els bots" antennaKeywordsDescription: "Separar amb espais per la condició AND o amb salts de línia per la condició OR." notifyAntenna: "Notifica'm les publicacions noves" withFileAntenna: "Només les publicacions amb fitxers" +excludeNotesInSensitiveChannel: "Excloure notes a canals sensibles" enableServiceworker: "Activar les notificacions al navegador" antennaUsersDescription: "Llistar un nom d'usuari per línia" caseSensitive: "Sensible a majúscules i minúscules " @@ -491,8 +491,6 @@ noteOf: "Publicació de: {user}" quoteAttached: "Frase adjunta" quoteQuestion: "Vols annexar-la com a cita?" attachAsFileQuestion: "El text copiat és massa llarg. Vols adjuntar-lo com un fitxer de text?" -noMessagesYet: "Encara no hi ha missatges" -newMessageExists: "Has rebut un nou missatge" onlyOneFileCanBeAttached: "Només pots adjuntar un fitxer a un missatge" signinRequired: "Si us plau, Registra't o inicia la sessió abans de continuar" signinOrContinueOnRemote: "Per continuar necessites moure el teu servidor o registrar-te / iniciar sessió en aquest servidor." @@ -539,7 +537,7 @@ mediaListWithOneImageAppearance: "Altura de la llista de fitxers amb una única limitTo: "Limita a {x}" noFollowRequests: "No tens sol·licituds de seguiment" openImageInNewTab: "Obre imatges a una nova pestanya" -dashboard: "Taulell de control" +dashboard: "Tauler de control" local: "Local" remote: "Remot" total: "Total" @@ -646,7 +644,7 @@ disablePlayer: "Tanca el reproductor de vídeo" expandTweet: "Expandir post" themeEditor: "Editor de temes" description: "Descripció" -describeFile: "Afegir subtitulació" +describeFile: "Afegeix una descripció " enterFileDescription: "Escriu un peu de foto" author: "Autor" leaveConfirm: "Hi ha canvis sense guardar. Els vols descartar?" @@ -654,7 +652,7 @@ manage: "Administració" plugins: "Extensions" preferencesBackups: "Configuracions de les Còpies de seguretat" deck: "Escriptori" -undeck: "Tanca l'escriptori" +undeck: "Tanca el tauler" useBlurEffectForModal: "Utilitzar l'efecte de difuminació a modals" useFullReactionPicker: "Utilitza el cercador de reaccions d'escala sencera" width: "Amplada" @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name} està parlant sobre \"{word}\"" makeActive: "Activar" display: "Veure" copy: "Copiar" +copiedToClipboard: "Copiat al porta papers" metrics: "Mètriques" overview: "Visió General" logs: "Registres" @@ -709,7 +708,7 @@ notificationSetting: "Paràmetres de notificacions" notificationSettingDesc: "Selecciona els tipus de notificacions que es mostraran" useGlobalSetting: "Fer servir la configuració global" useGlobalSettingDesc: "Si s'activa, es farà servir la configuració de notificacions del teu comte. Si no s'activa es poden fer configuracions individuals." -other: "Altre" +other: "Altres" regenerateLoginToken: "Regenerar clau de seguretat d'inici de sessió" regenerateLoginTokenDescription: "Regenera la clau de seguretat que es fa servir internament durant l'inici de sessió. Normalment aquesta acció no és necessària. Si es regenera es tancarà la sessió a tots els dispositius amb una sessió activa." theKeywordWhenSearchingForCustomEmoji: "Cercar un emoji personalitzat " @@ -872,7 +871,7 @@ gallery: "Galeria" recentPosts: "Articles recents" popularPosts: "Articles populars" shareWithNote: "Comparteix amb una nota" -ads: "Anuncis" +ads: "Publicitat " expiration: "" startingperiod: "Inici" memo: "Recordatori" @@ -916,7 +915,7 @@ off: "Desactivar" emailRequiredForSignup: "Demanar correu electrònic per registrar-se " unread: "Sense llegir" filter: "Filtrar" -controlPanel: "Taulell de control" +controlPanel: "Tauler de control" manageAccounts: "Gestionar comptes" makeReactionsPublic: "Reaccions públiques " makeReactionsPublicDescription: "Això fa que totes les teves reaccions siguin visibles públicament " @@ -980,6 +979,7 @@ document: "Documentació" numberOfPageCache: "Nombre de pàgines a la memòria cau" numberOfPageCacheDescription: "Incrementant aquest nombre farà que millori l'experiència de l'usuari, però es farà servir més memòria al dispositiu de l'usuari." logoutConfirm: "Vols sortir?" +logoutWillClearClientData: "En tancar la sessió, la informació del client al navegador s'esborrarà. Per garantir que la informació de configuració es pugui restaurar en tornar a iniciar sessió activa la còpia de seguretat automàtica de la configuració." lastActiveDate: "Fet servir per última vegada" statusbar: "Barra d'estat" pleaseSelect: "Selecciona una opció" @@ -1110,11 +1110,11 @@ accountMigration: "Migració del compte" accountMoved: "Aquest usuari té un compte nou:" accountMovedShort: "Aquest compte ha sigut migrat" operationForbidden: "Operació no permesa " -forceShowAds: "Mostra els anuncis sempre " +forceShowAds: "Mostrar publicitat sempre " addMemo: "Afegir recordatori" editMemo: "Editar recordatori" reactionsList: "Reaccions" -renotesList: "Impulsos" +renotesList: "Llistat d'impulsos " notificationDisplay: "Notificacions" leftTop: "Dalt a l'esquerra " rightTop: "Dalt a la dreta " @@ -1130,7 +1130,7 @@ pleaseAgreeAllToContinue: "Has d'acceptar tots els camps de dalt per poder conti continue: "Continuar" preservedUsernames: "Noms d'usuaris reservats" preservedUsernamesDescription: "Llistat de noms d'usuaris que no es poden fer servir separats per salts de linia. Aquests noms d'usuaris no estaran disponibles quan es creï un compte d'usuari normal, però els administradors els poden fer servir per crear comptes manualment. Per altre banda els comptes ja creats amb aquests noms d'usuari no es veure'n afectats." -createNoteFromTheFile: "Compon una nota des d'aquest fitxer" +createNoteFromTheFile: "Escriu una nota incloent aquest fitxer" archive: "Arxiu" archived: "Arxivat" unarchive: "Desarxivar" @@ -1139,7 +1139,7 @@ channelArchiveConfirmDescription: "Un Canal arxivat no apareixerà a la llista d thisChannelArchived: "Aquest Canal ha sigut arxivat." displayOfNote: "Mostrar notes" initialAccountSetting: "Configuració del perfil" -youFollowing: "Seguint" +youFollowing: "Segueixes " preventAiLearning: "Descartar l'ús d'aprenentatge automàtic (IA Generativa)" preventAiLearningDescription: "Demanar els indexadors no fer servir els texts, imatges, etc. en cap conjunt de dades per alimentar l'aprenentatge automàtic (IA Predictiva/ Generativa). Això s'aconsegueix afegint la etiqueta \"noai\" com a resposta HTML al contingut corresponent. Prevenir aquest ús totalment pot ser que no sigui aconseguit, ja que molts indexadors poden obviar aquesta etiqueta." options: "Opcions" @@ -1185,12 +1185,12 @@ iHaveReadXCarefullyAndAgree: "He llegit {x} i estic d'acord." dialog: "Diàleg " icon: "Icona" forYou: "Per a tu" -currentAnnouncements: "Informes actuals" -pastAnnouncements: "Informes passats" +currentAnnouncements: "Avisos actuals" +pastAnnouncements: "Avisos passats" youHaveUnreadAnnouncements: "Tens informes per llegir." useSecurityKey: "Segueix les instruccions del teu navegador O dispositiu per fer servir el teu passkey." -replies: "Respon" -renotes: "Impulsar " +replies: "Respostes" +renotes: "Impulsos" loadReplies: "Mostrar les respostes" loadConversation: "Mostrar la conversació " pinnedList: "Llista fixada" @@ -1309,6 +1309,136 @@ availableRoles: "Roles disponibles " acknowledgeNotesAndEnable: "Activa'l després de comprendre els possibles perills." federationSpecified: "Aquest servidor treballa amb una federació de llistes blanques. No pot interactuar amb altres servidors que no siguin els especificats per l'administrador." federationDisabled: "La unió es troba deshabilitada en aquest servidor. No es pot interactuar amb usuaris d'altres servidors." +confirmOnReact: "Confirmar en reaccionar" +reactAreYouSure: "Vols reaccionar amb \"{emoji}\"?" +markAsSensitiveConfirm: "Vols marcar aquest contingut com a sensible?" +unmarkAsSensitiveConfirm: "Vols deixar de marcar com a sensible aquest contingut?" +preferences: "Preferències " +accessibility: "Accessibilitat " +preferencesProfile: "Perfil de configuració " +copyPreferenceId: "Copiar l'ID de la configuració " +resetToDefaultValue: "Restaura al valor per defecte " +overrideByAccount: "Anul·lar per compte" +untitled: "Sense títol " +noName: "No hi ha un nom disponible " +skip: "Ometre " +restore: "Restaurar " +syncBetweenDevices: "Sincronització entre dispositius" +preferenceSyncConflictTitle: "Els valors de la configuració ja existeixen al dispositiu" +preferenceSyncConflictText: "Un element de la configuració amb sincronització activada desa els seus valors al servidor, però s'ha trobat un valor a la configuració desat al servidor per aquest element de la configuració. Quin valor us sobreescriure?" +preferenceSyncConflictChoiceServer: "Valors de configuració del servidor" +preferenceSyncConflictChoiceDevice: "Punts d'ajustos del dispositiu " +preferenceSyncConflictChoiceCancel: "Cancel·lar l'activació de la sincronització " +paste: "Pegar" +emojiPalette: "Calaix d'emojis" +postForm: "Formulari de publicació" +textCount: "Nombre de caràcters " +information: "Informació" +chat: "Xat" +migrateOldSettings: "Migrar la configuració anterior" +migrateOldSettings_description: "Normalment això es fa automàticament, però si la transició no es fa, el procés es pot iniciar manualment. S'esborrarà la configuració actual." +compress: "Comprimir " +right: "Dreta" +bottom: "A baix " +top: "A dalt " +embed: "Incrustar" +settingsMigrating: "Estem migrant la teva configuració. Si us plau espera un moment... (També pots fer la migració més tard, manualment, anant a Preferències → Altres → Migrar configuració antiga)" +readonly: "Només lectura" +goToDeck: "Tornar al tauler" +federationJobs: "Treballs sindicats " +driveAboutTip: "Al Disc veure's una llista de tots els arxius que has anat pujant.
\nPots tornar-los a fer servir adjuntant-los a notes noves o pots adelantar-te i pujar arxius per publicar-los més tard!
\nTingués en compte que si esborres un arxiu també desapareixerà de tots els llocs on l'has fet servir (notes, pàgines, avatars, imatges de capçalera, etc.)
\nTambé pots crear carpetes per organitzar les." +_chat: + noMessagesYet: "Encara no tens missatges " + newMessage: "Missatge nou" + individualChat: "Xat individual " + individualChat_description: "Pots mantenir converses individuals amb usuaris concrets." + roomChat: "Sala de xat" + roomChat_description: "Pots xatejar amb diverses persones.\nTambé pots xatejar amb usuaris que no poden fer xats privats, si ells accepten." + createRoom: "Crear una sala" + inviteUserToChat: "Invita usuaris per començar a xatejar" + yourRooms: "Sales creades" + joiningRooms: "Sales a les quals participes" + invitations: "Convida" + noInvitations: "No tens cap invitació " + history: "Historial de converses " + noHistory: "No hi ha un registre previ" + noRooms: "No hi ha cap sala" + inviteUser: "Invitar usuaris" + sentInvitations: "Enviar invitacions" + join: "Afegir-se " + ignore: "Ignorar " + leave: "Marxar" + members: "Membres" + searchMessages: "Buscar missatges " + home: "Inici" + send: "Envia" + newline: "Línia nova " + muteThisRoom: "Silenciar aquesta sala" + deleteRoom: "Esborrar la sala" + chatNotAvailableForThisAccountOrServer: "El xat no està disponible per aquest servidor o aquest compte." + chatIsReadOnlyForThisAccountOrServer: "El xat és només de lectura en aquest servidor o compte. No es poden escriure nous missatges ni crear o unir-se a sales de xat." + chatNotAvailableInOtherAccount: "La funció de xat es troba desactivada al compte de l'altre usuari." + cannotChatWithTheUser: "No pots xatejar amb aquest usuari" + cannotChatWithTheUser_description: "El xat està desactivat o l'altra part encara no l'ha obert." + chatWithThisUser: "Xateja amb aquest usuari" + thisUserAllowsChatOnlyFromFollowers: "Aquest usuari només accepta xats d'usuaris que el segueixen." + thisUserAllowsChatOnlyFromFollowing: "Aquest usuari només accepta xats d'usuaris que segueix." + thisUserAllowsChatOnlyFromMutualFollowing: "Aquest usuari només accepta xats d'usuaris que segueixes i et segueixen." + thisUserNotAllowedChatAnyone: "Aquest usuari no accepta xats de ningú." + chatAllowedUsers: "Usuaris que poden xatejar" + chatAllowedUsers_note: "Pots xatejar amb qualsevol usuari a qui hagis enviat un missatge de xat, independentment d'aquesta configuració." + _chatAllowedUsers: + everyone: "Tothom" + followers: "Només els teus seguidors" + following: "Només usuaris als que segueixes" + mutual: "Només seguidors mutus" + none: "Ningú " +_emojiPalette: + palettes: "Calaixos d'emojis" + enableSyncBetweenDevicesForPalettes: "Activa la sincronització dels calaixos d'emojis entre dispositius" + paletteForMain: "Calaix d'emojis principal" + paletteForReaction: "Calaix d'emojis per reaccions" +_settings: + driveBanner: "Pots gestionar i configurar el Disc, comprovar el seu ús i establir una configuració per a la càrrega d'arxius." + pluginBanner: "Els complements poden fer-se servir per ampliar les funcionalitats del client. Els complements poden instal·lar-se, configurar-se individualment i gestionar-se." + notificationsBanner: "Pots configurar el tipus i l'abast de les notificacions que es rebran del servidor, també les notificacions emergents." + api: "API" + webhook: "Webhook" + serviceConnection: "Relació entre serveis" + serviceConnectionBanner: "Pots configurar i gestionar tokens d'accés i webhooks per integrar serveis i aplicacions externes." + accountData: "Dades del compte" + accountDataBanner: "Exportació/Importació i gestió d'arxius amb dades del compte." + muteAndBlockBanner: "Pots configurar i gestionar els continguts que desitges amagar i restringir les accions de determinats usuaris." + accessibilityBanner: "Els clients poden personalitzar-se i configurar-se per un ús òptim en funció de la seva visió i comportament." + privacyBanner: "Pots establir la configuració de privacitat del compte, com el grau de visibilitat del teu contingut, la facilitat per trobar-ho i si es pot aprovar els seguidors." + securityBanner: "Configura les opcions relacionades amb la seguretat del teu compte com ara contrasenyes, mètodes per iniciar sessió, aplicacions d'autentificació i claus d'accés." + preferencesBanner: "Pots configurar el comportament general del client segons les teves preferències." + appearanceBanner: "Pots configurar les preferències relacionades amb la visualització i l'aspecte del client segons el teu parer." + soundsBanner: "Configuració dels sons que reproduirà el client." + timelineAndNote: "Línia de temps i nota" + makeEveryTextElementsSelectable: "Fes que tots els elements del text siguin seleccionables" + makeEveryTextElementsSelectable_description: "L'activació pot reduir la usabilitat en determinades ocasions." + useStickyIcons: "Utilitza icones fixes" + showNavbarSubButtons: "Mostrar sub botons a la barra de navegació " + ifOn: "Quan s'activa" + ifOff: "Quan es desactiva" + enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius" + _chat: + showSenderName: "Mostrar el nom del remitent" + sendOnEnter: "Introdueix per enviar" +_preferencesProfile: + profileName: "Nom del perfil" + profileNameDescription: "Estableix un nom que identifiqui aquest dispositiu." + profileNameDescription2: "Per exemple: \"PC Principal\", \"Smartphone\", etc" +_preferencesBackup: + autoBackup: "Còpia de seguretat automàtica " + restoreFromBackup: "Restaurar des d'una còpia de seguretat" + noBackupsFoundTitle: "No s'ha trobat cap còpia de seguretat" + noBackupsFoundDescription: "No s'han trobat còpies de seguretat creades automàticament, però si has desat, manualment, un arxiu de còpia de seguretat, pots importar-lo i carregar-lo." + selectBackupToRestore: "Seleccionar la còpia de seguretat que vols restaurar" + youNeedToNameYourProfileToEnableAutoBackup: "Has de posar-li un nom al teu perfil per poder activar les còpies de seguretat automàtiques." + autoPreferencesBackupIsNotEnabledForThisDevice: "La còpia de seguretat automàtica no es troba activada en aquest dispositiu." + backupFound: "Còpia de seguretat de la configuració trobada" _accountSettings: requireSigninToViewContents: "És obligatori l'inici de sessió per poder veure el contingut" requireSigninToViewContentsDescription1: "Es requereix l'inici de sessió per poder veure totes les notes i el contingut que has creat. Amb això esperem evitar que els rastrejadors recopilin informació." @@ -1319,6 +1449,7 @@ _accountSettings: makeNotesHiddenBefore: "Fes que les notes antigues siguin privades" makeNotesHiddenBeforeDescription: "Mentres aquesta funció estigui activada les notes que hagin superat una data i hora fixada o hagi passat el temps establert només seran visibles per a tu. Si la desactives es restablirà també l'estat públic de les notes." mayNotEffectForFederatedNotes: "Això pot ser que no afecti les notes federades." + mayNotEffectSomeSituations: "Aquestes restriccions són simplificades. Pot ser que no s'apliquin en determinades situacions, com quan es modera o visualitza un servidor remot." notesHavePassedSpecifiedPeriod: "Notes publicades durant un període de temps especificat." notesOlderThanSpecifiedDateAndTime: "Notes més antigues de la data i temps especificat " _abuseUserReport: @@ -1330,7 +1461,7 @@ _abuseUserReport: resolveTutorial: "Si l'informe és legítim selecciona \"Acceptar\" per resoldre'l positivament. Però si l'informe no és legítim selecciona \"Rebutjar\" per resoldre'l negativament." _delivery: status: "Estat d'entrega " - stop: "Suspés" + stop: "Anul·lar subscripció " resume: "Torna a enviar" _type: none: "S'està publicant" @@ -1358,7 +1489,7 @@ _announcement: needConfirmationToRead: "Es necessita confirmació de lectura de la notificació " needConfirmationToReadDescription: "Si s'activa es mostrarà un diàleg per confirmar la lectura d'aquesta notificació. A més aquesta notificació serà exclosa de qualsevol funcionalitat com \"Marcar tot com a llegit\"." end: "Final de la notificació " - tooManyActiveAnnouncementDescription: "Tenir massa notificacions actives pot empitjorar l'experiència de l'usuari. Considera finalitzar els anuncis que siguin antics." + tooManyActiveAnnouncementDescription: "Tenir massa notificacions actives pot empitjorar l'experiència de l'usuari. Considera finalitzar els avisos que siguin antics." readConfirmTitle: "Marcar com llegida?" readConfirmText: "Això marcarà el contingut de \"{title}\" com llegit." shouldNotBeUsedToPresentPermanentInfo: "Ja que l'ús de notificacions pot impactar l'experiència dels nous usuaris, és recomanable fer servir les notificacions amb el flux d'informació en comptes de fer-les servir en un únic bloc." @@ -1476,7 +1607,7 @@ _accountMigration: moveTo: "Migrar aquest compte a un altre" moveToLabel: "Compte al qual es vol migrar:" moveCannotBeUndone: "Les migracions dels comptes no es poden desfer." - moveAccountDescription: "Això migrarà la teva compte a un altre diferent.\n ・Els seguidors d'aquest compte és passaran al compte nou de forma automàtica\n ・Es deixaran de seguir a tots els usuaris que es segueixen actualment en aquest compte\n ・No es poden crear notes noves, etc. en aquest compte\n\nSi bé la migració de seguidors es automàtica, has de preparar alguns pasos manualment per migrar la llista d'usuaris que segueixes. Per fer això has d'exportar els seguidors que després importaraes al compte nou mitjançant el menú de configuració. El mateix procediment s'ha de seguir per less teves llistes i els teus usuaris silenciats i bloquejats.\n\n(Aquesta explicació s'aplica a Misskey v13.12.0 i posteriors. Altres aplicacions, com Mastodon, poden funcionar diferent.)" + moveAccountDescription: "Això migrarà el teu compte a un altre diferent.\n ・Els seguidors d'aquest compte és passaran al compte nou de forma automàtica\n ・Es deixaran de seguir a tots els usuaris que es segueixen actualment en aquest compte\n ・No es poden crear notes noves, etc. en aquest compte\n\nSi bé la migració de seguidors es automàtica, has de preparar alguns pasos manualment per migrar la llista d'usuaris que segueixes. Per fer això has d'exportar els seguidors que després importaraes al compte nou mitjançant el menú de configuració. El mateix procediment s'ha de seguir per less teves llistes i els teus usuaris silenciats i bloquejats.\n\n(Aquesta explicació s'aplica a Misskey v13.12.0 i posteriors. Altres aplicacions, com Mastodon, poden funcionar diferent.)" moveAccountHowTo: "Per fer la migració, primer has de crear un àlies per aquest compte al compte al qual vols migrar.\nDesprés de crear l'àlies, introdueix el compte al qual vols migrar amb el format següent: @nomusuari@servidor.exemple.com" startMigration: "Migrar" migrationConfirm: "Vols migrar aquest compte a {account}? Una vegada comenci la migració no es podrà parar O fer marxa enrere i no podràs tornar a fer servir aquest compte mai més." @@ -1764,6 +1895,8 @@ _role: descriptionOfIsExplorable: "La línia de temps d'aquest rol i la llista d'usuaris seran públics si s'activa." displayOrder: "Posició " descriptionOfDisplayOrder: "Com més gran és el número, més dalt la seva posició a la interfície." + preserveAssignmentOnMoveAccount: "L'estat de l'assignació també es trasllada amb el compte migrat" + preserveAssignmentOnMoveAccount_description: "Si s'activa quan es migra un compte amb aquest rol, el compte migrat també heretarà aquest rol." canEditMembersByModerator: "Permetre que els moderadors editin la llista d'usuaris en aquest rol" descriptionOfCanEditMembersByModerator: "Quan s'activa, els moderadors, així com els administradors, podran afegir i treure usuaris d'aquest rol. Si es troba desactivat, només els administradors poden assignar usuaris." priority: "Prioritat" @@ -1783,6 +1916,7 @@ _role: canManageCustomEmojis: "Gestiona els emojis personalitzats" canManageAvatarDecorations: "Gestiona les decoracions dels avatars " driveCapacity: "Capacitat del disc" + maxFileSize: "Mida màxima de l'arxiu que es pot carregar" alwaysMarkNsfw: "Marca sempre els fitxers com a sensibles" canUpdateBioMedia: "Permet l'edició d'una icona o un bàner" pinMax: "Nombre màxim de notes fixades" @@ -1795,7 +1929,7 @@ _role: userEachUserListsMax: "Nombre màxim d'usuaris dintre d'una llista d'usuaris " rateLimitFactor: "Limitador" descriptionOfRateLimitFactor: "Límits baixos són menys restrictius, límits alts són més restrictius." - canHideAds: "Pot amagar els anuncis" + canHideAds: "Pot amagar la publicitat" canSearchNotes: "Pot cercar notes" canUseTranslator: "Pot fer servir el traductor" avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars" @@ -1804,6 +1938,7 @@ _role: canImportFollowing: "Autoritza la importació de seguidors" canImportMuting: "Autoritza la importació de silenciats" canImportUserLists: "Autoritza la importació de llistes d'usuaris " + chatAvailability: "Es permet xatejar" _condition: roleAssignedTo: "Assignat a rols manuals" isLocal: "Usuari local" @@ -1859,8 +1994,8 @@ _ad: reduceFrequencyOfThisAd: "Mostrar menys aquest anunci" hide: "No mostrar mai" timezoneinfo: "El dia de la setmana ve determinat del fus horari del servidor." - adsSettings: "Configuració d'anuncis " - notesPerOneAd: "Interval d'emplaçament d'anuncis en temps real (Notes per anuncis)" + adsSettings: "Configurar la publicitat" + notesPerOneAd: "Interval d'emplaçament publicitari en temps real (Notes per anuncis)" setZeroToDisable: "Ajusta aquest valor a 0 per deshabilitar l'actualització d'anuncis en temps real" adsTooClose: "L'interval actual pot fer que l'experiència de l'usuari sigui dolenta perquè l'interval és molt baix." _forgotPassword: @@ -1967,6 +2102,7 @@ _theme: installed: "{name} Instal·lat " installedThemes: "Temes instal·lats " builtinThemes: "Temes integrats" + instanceTheme: "Tema de la instància " alreadyInstalled: "Aquest tema ja es troba instal·lat " invalid: "El format d'aquest tema no és correcte" make: "Crear un tema" @@ -1994,19 +2130,18 @@ _theme: fg: "Text" focus: "Enfocament" indicator: "Indicador" - panel: "Taulell " + panel: "Tauler" shadow: "Ombra" header: "Capçalera" navBg: "Fons de la barra lateral" navFg: "Text de la barra lateral" - navHoverFg: "Text barra lateral (en passar per sobre)" navActive: "Text barra lateral (actiu)" navIndicator: "Indicador barra lateral" link: "Enllaç" hashtag: "Etiqueta" mention: "Menció" mentionMe: "Mencions (jo)" - renote: "Renotar" + renote: "Impulsar" modalBg: "Fons del modal" divider: "Divisor" scrollbarHandle: "Maneta de la barra de desplaçament" @@ -2022,17 +2157,15 @@ _theme: buttonHoverBg: "Fons botó (en passar-hi per sobre)" inputBorder: "Contorn del cap d'introducció " driveFolderBg: "Fons de la carpeta Disc" - wallpaperOverlay: "Superposició del fons de pantalla " badge: "Insígnia " messageBg: "Fons del xat" - accentDarken: "Accent (fosc)" - accentLighten: "Accent (clar)" fgHighlighted: "Text ressaltat" _sfx: note: "Notes" noteMy: "Nota (per mi)" notification: "Notificacions" reaction: "Quan se selecciona una reacció " + chatMessage: "Missatges del xat" _soundSettings: driveFile: "Fer servir un fitxer d'àudio del disc" driveFileWarn: "Seleccionar un fitxer d'àudio del disc" @@ -2179,6 +2312,8 @@ _permissions: "read:clip-favorite": "Veure clips favorits" "read:federation": "Veure dades de federació" "write:report-abuse": "Informar d'un abús" + "write:chat": "Crear o esborrar missatges de xat" + "read:chat": "Explorar xats" _auth: shareAccessTitle: "Concedeix permisos a l'aplicació" shareAccess: "Vols que {name} pugui accedir al vostre compte?" @@ -2237,6 +2372,7 @@ _widgets: chooseList: "Tria una llista" clicker: "Clicker" birthdayFollowings: "Usuaris que fan l'aniversari avui" + chat: "Xat" _cw: hide: "Amagar" show: "Carregar més" @@ -2427,6 +2563,7 @@ _notification: newNote: "Nota nova" unreadAntennaNote: "Antena {name}" roleAssigned: "Rol assignat " + chatRoomInvitationReceived: "T'han invitat a una sala de xat" emptyPushNotificationMessage: "Les notificacions han sigut actualitzades" achievementEarned: "Aconseguiment desblocat" testNotification: "Notificació de prova" @@ -2440,32 +2577,39 @@ _notification: flushNotification: "Netejar notificacions" exportOfXCompleted: "Completada l'exportació de {x}" login: "Algú ha iniciat sessió " + createToken: "Token d'accés generat" + createTokenDescription: "Si no saps què és, esborra el token des de {text}." _types: all: "Tots" note: "Notes noves" follow: "Segueix-me" mention: "Menció" reply: "Respostes" - renote: "Renotar" + renote: "Impulsos" quote: "Citar" reaction: "Reaccions" pollEnded: "Enquesta terminada" receiveFollowRequest: "Rebuda una petició de seguiment" followRequestAccepted: "Petició de seguiment acceptada" roleAssigned: "Rol donat" + chatRoomInvitationReceived: "Invitat a la sala de xat" achievementEarned: "Assoliment desbloquejat" exportCompleted: "Exportació completada" login: "Iniciar sessió" + createToken: "Creació de tokens d'accés " test: "Prova la notificació" app: "Notificacions d'aplicacions" _actions: followBack: "També et segueix" reply: "Respondre" - renote: "Renotar" + renote: "Impulsar" _deck: alwaysShowMainColumn: "Mostrar sempre la columna principal" columnAlign: "Alinea les columnes" - addColumn: "Afig una columna" + columnGap: "Espai entre columnes" + deckMenuPosition: "Posició del menú del tauler" + navbarPosition: "Posició de la barra de navegació " + addColumn: "Afegeix una columna" newNoteNotificationSettings: "Configuració de notificacions per a notes noves" configureColumn: "Configuració de columnes" swapLeft: "Mou a l’esquerra" @@ -2483,6 +2627,7 @@ _deck: useSimpleUiForNonRootPages: "Usa una interfície senzilla per a les pàgines navegades" usedAsMinWidthWhenFlexible: "L'amplada mínima es farà servir quan \"Ajust automàtic de l'amplada\" estigui activat" flexible: "Ajust automàtic de l'amplada" + enableSyncBetweenDevicesForProfiles: "Activar la sincronització de la informació de perfils de dispositiu a dispositiu" _columns: main: "Principal" widgets: "Ginys" @@ -2494,6 +2639,7 @@ _deck: mentions: "Mencions" direct: "Publicacions directes" roleTimeline: "Línia de temps dels rols" + chat: "Xat" _dialog: charactersExceeded: "Has arribat al màxim de caràcters! Actualment és {current} de {max}" charactersBelow: "Ets per sota del mínim de caràcters! Actualment és {current} de {min}" @@ -2590,6 +2736,8 @@ _moderationLogTypes: deletePage: "Esborrar la pàgina" deleteFlash: "Esborrar el guió" deleteGalleryPost: "Esborrar la publicació de la galeria" + deleteChatRoom: "Esborra la sala de xat" + updateProxyAccountDescription: "Actualitzar descripció del compte proxy" _fileViewer: title: "Detall del fitxer" type: "Tipus de fitxer" @@ -2603,10 +2751,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Assegura't que qui distribueix aquest recurs és fiable abans d'instal·lar-ho." _plugin: title: "Vols instal·lar aquest afegit?" - metaTitle: "Informació de l'afegit " _theme: title: "Vols instal·lar aquest tema?" - metaTitle: "Informació del tema" _meta: base: "Paleta de colors base" _vendorInfo: @@ -2659,7 +2805,7 @@ _hemisphere: _reversi: reversi: "Reversi" gameSettings: "Opcions del joc" - chooseBoard: "Escull un taulell" + chooseBoard: "Escull un tauler" blackOrWhite: "Negres/Blanques" blackIs: "{name} juga amb negres " rules: "Regles" @@ -2822,8 +2968,6 @@ _remoteLookupErrors: _responseInvalid: title: "La resposta no és correcta " description: "Hem pogut comunicar-nos amb aquest servidor, però les dades rebudes no són correctes." - _responseInvalidIdHostNotMatch: - description: "El domini de l'adreça introduïda no és el mateix que el domini de l'adreça final obtinguda. Si estàs consultant continguts remots mitjançant servidors tercers, torna a fer la consulta fent servir l'adreça que es pot obtenir en el servidor origen." _noSuchObject: title: "No s'ha trobat" description: "No es pot trobar el recurs sol·licitat, si us plau comprova l'adreça una altra vegada." @@ -2840,3 +2984,23 @@ _captcha: _unknown: title: "Error CAPTCHA" text: "S'ha produït un error inesperat." +_bootErrors: + title: "Hi ha hagut en error en carregar" + serverError: "Si el problema persisteix després d'esperar una mica i recarregar, posa't en contacte amb l'administrador del servidor amb el següent codi d'error." + solution: "Per intentar resoldre el problema pots fer el següent." + solution1: "Actualitza el navegador i el sistema operatiu a l'última versió " + solution2: "Desactiva els adblockers" + solution3: "Esborra la memòria cau del navegador" + solution4: "(Navegador Tor) configura dom.webaudio.enabled a true" + otherOption: "Altres opcions" + otherOption1: "Esborrar la configuració i la memòria cau del client" + otherOption2: "Iniciar client senzill" + otherOption3: "Iniciar l'eina de reparació " +_search: + searchScopeAll: "Tot" + searchScopeLocal: "Local" + searchScopeServer: "Instància " + searchScopeUser: "Especificar usuari" + pleaseEnterServerHost: "Introdueix l'adreça de la instància " + pleaseSelectUser: "Selecciona un usuari" + serverHostPlaceholder: "Ex: misskey.example.com" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index afa3047c1d..87f9445409 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -5,9 +5,13 @@ introMisskey: "Vítejte! Misskey je otevřený a decentralizovaný microblogový poweredByMisskeyDescription: "{name} je jeden ze serverů využívající open source platformu Misskey (nazývaná \"Misskey instance\")." monthAndDay: "{day}. {month}." search: "Vyhledávání" +reset: "Obnovit" notifications: "Oznámení" username: "Uživatelské jméno" password: "Heslo" +initialPasswordForSetup: "Počáteční heslo pro nastavení" +initialPasswordIsIncorrect: "Počáteční heslo pro nastavení je nesprávné" +initialPasswordForSetupDescription: "Použijte heslo, které jste nastavili v konfiguračním souboru, pokud jste Misskey instalovali ručně.\nPokud užíváte Misskey hostovací službu, použijte poskytnuté heslo.\nPokud jste heslo nenastavovali, zanechte prázdné." forgotPassword: "Zapomenuté heslo" fetchingAsApObject: "Načítám data z Fediversu..." ok: "Potvrdit" @@ -45,6 +49,8 @@ pin: "Připnout" unpin: "Odepnout" copyContent: "Zkopírovat obsah" copyLink: "Kopírovat odkaz" +copyRemoteLink: "Zkoprírovat vzdálený odkaz" +copyLinkRenote: "Zkopírovat odkaz renotu" delete: "Smazat" deleteAndEdit: "Smazat a upravit" deleteAndEditConfirm: "Jste si jistí že chcete smazat tuto poznámku a editovat ji? Ztratíte tím všechny reakce, sdílení a odpovědi na ni." @@ -168,6 +174,9 @@ addAccount: "Přidat účet" reloadAccountsList: "Obnovit list účtů" loginFailed: "Přihlášení se nezdařilo." showOnRemote: "Více na původním profilu" +continueOnRemote: "Pokračujte na původní profil" +chooseServerOnMisskeyHub: "Vyberete si server z Misskey Hubu" +inputHostName: "Zadejte doménu" general: "Obecně" wallpaper: "Obrázek na pozadí" setWallpaper: "Nastavení obrázku na pozadí" @@ -192,6 +201,7 @@ perHour: "za hodinu" perDay: "za den" stopActivityDelivery: "Přestat zasílat aktivitu" blockThisInstance: "Blokovat tuto instanci" +silenceThisInstance: "Utišit tuto instanci" operations: "Operace" software: "Software" version: "Verze" @@ -256,7 +266,6 @@ removeAreYouSure: "Jste si jistí že chcete smazat \"{x}\"?" deleteAreYouSure: "Jste si jistí že chcete smazat \"{x}\"?" resetAreYouSure: "Opravdu resetovat?" saved: "Uloženo" -messaging: "Zprávy" upload: "Nahrát soubory" keepOriginalUploading: "Ponechat originální obrázek" keepOriginalUploadingDescription: "Uloží původní nahraný obrázek jak je. Pokud je to vypnuté, vygeneruje se zobrazení verze na webu při nahrátí." @@ -269,7 +278,6 @@ uploadFromUrlMayTakeTime: "Může trvat nějakou dobu, dokud nebude dokončeno n explore: "Objevovat" messageRead: "Přečtené" noMoreHistory: "To je vše" -startMessaging: "Zahájit chat" nUsersRead: "přečteno {n} uživateli" agreeTo: "Souhlasím s {0}" agree: "Souhlasím" @@ -365,8 +373,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "Aktivovat hCaptchu" hcaptchaSiteKey: "Klíč stránky" hcaptchaSecretKey: "Tajný Klíč (Secret Key)" +mcaptcha: "mCaptcha" +enableMcaptcha: "Aktivovat mCaptchu" mcaptchaSiteKey: "Klíč stránky" mcaptchaSecretKey: "Tajný Klíč (Secret Key)" +mcaptchaInstanceUrl: "URL mCaptcha serveru" recaptcha: "reCAPTCHA" enableRecaptcha: "Zapnout ReCAPTCHu" recaptchaSiteKey: "Klíč stránky" @@ -445,8 +456,6 @@ retype: "Zadejte znovu" noteOf: "{user} poznámky" quoteAttached: "Citace" quoteQuestion: "Přiložit jako citaci?" -noMessagesYet: "Zatím tu nejsou žádné zprávy" -newMessageExists: "Máte novou zprávu" onlyOneFileCanBeAttached: "Ke zprávě můžete přiložit jenom jeden soubor" signinRequired: "Přihlašte se, prosím" invitations: "Pozvat" @@ -470,6 +479,8 @@ uiLanguage: "Jazyk uživatelského rozhraní" aboutX: "O {x}" emojiStyle: "Styl emoji" native: "Výchozí" +style: "Vzhled" +popup: "Vyskakovací okno" showNoteActionsOnlyHover: "Zobrazit akce poznámky jenom při naběhnutí myši" noHistory: "Žádná historie" signinHistory: "Historie přihlášení" @@ -532,6 +543,7 @@ showInPage: "Zobrazit na stránce" popout: "Pop-out" volume: "Hlasitost" masterVolume: "Celková hlasitost" +notUseSound: "Zakázat zvuk" details: "Detaily" chooseEmoji: "Vybrat emotikon" unableToProcess: "Operace nebyla dokončena." @@ -1094,6 +1106,14 @@ sourceCode: "Zdrojový kód" flip: "Otočit" lastNDays: "Posledních {n} dnů" surrender: "Zrušit" +postForm: "Formulář pro odeslání" +information: "Informace" +_chat: + invitations: "Pozvat" + noHistory: "Žádná historie" + members: "Členové" + home: "Domů" + send: "Odeslat" _delivery: stop: "Suspendováno" _type: @@ -1606,7 +1626,6 @@ _theme: header: "Nadpis" navBg: "Pozadí postranního panelu" navFg: "Text na postranním panelu" - navHoverFg: "Text na postranním panelu (Hover)" navActive: "Text na postranním panelu (Aktivní)" navIndicator: "Indikátor na postranním panelu" link: "Odkaz" @@ -1629,11 +1648,8 @@ _theme: buttonHoverBg: "Pozadí tlačítka (Hover)" inputBorder: "Ohraničení vstupního pole" driveFolderBg: "Pozadí složky disku" - wallpaperOverlay: "Překrytí tapety" badge: "Odznak" messageBg: "Pozadí chatu" - accentDarken: "Akcent (Ztmavený)" - accentLighten: "Akcent (Zesvětlený)" fgHighlighted: "Zvýrazněný text" _sfx: note: "Poznámky" @@ -1709,6 +1725,7 @@ _permissions: "write:gallery": "Upravit galerii" "read:gallery-likes": "Zobrazit seznam to se mi líbí příspěvků v galerii" "write:gallery-likes": "Upravit seznam to se mi líbí příspěvků v galerii" + "write:chat": "Sestavit nebo mazat zprávy chatu" _auth: shareAccessTitle: "Udělovat oprávnění k aplikacím" shareAccess: "Chcete autorizovat \"{name}\" pro přístup k tomuto účtu?" @@ -2024,3 +2041,7 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "Nenalezeno" +_search: + searchScopeAll: "Vše" + searchScopeLocal: "Místní" + searchScopeUser: "Upřesnit uživatele" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 11fe6d3ff5..41c3f495a2 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -49,7 +49,7 @@ pin: "An dein Profil anheften" unpin: "Von deinem Profil lösen" copyContent: "Inhalt kopieren" copyLink: "Link kopieren" -copyRemoteLink: "Renote-Link kopieren" +copyRemoteLink: "Remote-Link kopieren" copyLinkRenote: "Renote-Link kopieren" delete: "Löschen" deleteAndEdit: "Löschen und Bearbeiten" @@ -289,7 +289,6 @@ deleteAreYouSure: "Möchtest du „{x}“ wirklich löschen?" resetAreYouSure: "Wirklich zurücksetzen?" areYouSure: "Bist du sicher?" saved: "Erfolgreich gespeichert" -messaging: "Chat" upload: "Hochladen" keepOriginalUploading: "Originalbild speichern" keepOriginalUploadingDescription: "Speichert das Originalbild so, wie es ist. Ist dies deaktiviert, wird eine Version zum Anzeigen im Internet generiert." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "Es kann eine Weile dauern, bis das Hochladen abgeschl explore: "Erkunden" messageRead: "Gelesen" noMoreHistory: "Kein weiterer Verlauf vorhanden" -startMessaging: "Neuen Chat erstellen" +startChat: "Chat starten" nUsersRead: "Von {n} Benutzern gelesen" agreeTo: "Ich stimme {0} zu" agree: "Zustimmen" @@ -425,6 +424,7 @@ antennaExcludeBots: "Bot-Accounts ausschließen" antennaKeywordsDescription: "Zum Nutzen einer \"UND\"-Verknüpfung Einträge mit Leerzeichen trennen, zum Nutzen einer \"ODER\"-Verknüpfung Einträge mit einem Zeilenumbruch trennen" notifyAntenna: "Über neue Notizen benachrichtigen" withFileAntenna: "Nur Notizen mit Dateien" +excludeNotesInSensitiveChannel: "Schließe Notizen von sensitive Kanäle aus" enableServiceworker: "Push-Benachrichtigungen im Browser aktivieren" antennaUsersDescription: "Benutzernamen getrennt durch Zeilenumbrüche angeben" caseSensitive: "Groß-/Kleinschreibung unterscheiden" @@ -491,8 +491,6 @@ noteOf: "Notiz von {user}" quoteAttached: "Zitat" quoteQuestion: "Als Zitat anhängen?" attachAsFileQuestion: "Der Text in der Zwischenablage ist lang. Möchtest du ihn als Textdatei anhängen?" -noMessagesYet: "Noch keine Nachrichten vorhanden" -newMessageExists: "Du hast eine neue Nachricht" onlyOneFileCanBeAttached: "Es kann pro Nachricht nur eine Datei angehängt werden" signinRequired: "Bitte registriere oder melde dich an, um fortzufahren" signinOrContinueOnRemote: "Um fortzufahren, gehe zu deiner Instanz oder registriere bzw. melde dich an dieser Instanz an. " @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name} sagt etwas über '{word}'" makeActive: "Aktivieren" display: "Anzeigeart" copy: "Kopieren" +copiedToClipboard: "In die Zwischenablage kopiert" metrics: "Metriken" overview: "Übersicht" logs: "Protokolle" @@ -863,7 +862,7 @@ administration: "Verwaltung" accounts: "Benutzerkonten" switch: "Wechseln" noMaintainerInformationWarning: "Betreiberinformationen sind nicht konfiguriert." -noInquiryUrlWarning: "Keine gültige URL." +noInquiryUrlWarning: "Keine gültige Kontakt-URL." noBotProtectionWarning: "Schutz vor Bots ist nicht konfiguriert." configure: "Konfigurieren" postToGallery: "Neuen Galeriebeitrag erstellen" @@ -964,8 +963,8 @@ cropImageAsk: "Möchtest du das Bild zuschneiden?" cropYes: "Zuschneiden" cropNo: "Unbearbeitet verwenden" file: "Datei" -recentNHours: "Letzten {n} Stunden" -recentNDays: "Letzten {n} Tage" +recentNHours: "Letzte {n} Stunden" +recentNDays: "Letzte {n} Tage" noEmailServerWarning: "Es ist kein Email-Server konfiguriert." thereIsUnresolvedAbuseReportWarning: "Es liegen ungelöste Meldungen vor." recommended: "Empfehlung" @@ -973,13 +972,14 @@ check: "Check" driveCapOverrideLabel: "Die Drive-Kapazität dieses Nutzers verändern" driveCapOverrideCaption: "Gib einen Wert von 0 oder weniger ein, um die Kapazität auf den Standard zurückzusetzen." requireAdminForView: "Melde dich mit einem Administratorkonto an, um dies einzusehen." -isSystemAccount: "Ein Benutzerkonto, dass durch das System erstellt und automatisch kontrolliert wird." +isSystemAccount: "Ein Benutzerkonto, das durch das System erstellt und automatisch verwaltet wird." typeToConfirm: "Bitte gib zur Bestätigung {x} ein" deleteAccount: "Benutzerkonto löschen" document: "Dokumentation" numberOfPageCache: "Seitencachegröße" numberOfPageCacheDescription: "Das Erhöhen dieses Caches führt zu einer angenehmerern Benutzererfahrung, aber erhöht Last und Arbeitsspeicherauslastung auf dem Nutzergerät." logoutConfirm: "Wirklich abmelden?" +logoutWillClearClientData: "Beim Abmelden werden die Konfigurationsdaten des Clients aus dem Browser gelöscht. Um sicherzustellen, dass die Konfigurationsdaten beim erneuten Einloggen wiederhergestellt werden können, aktivieren Sie bitte die automatische Sicherung der Konfiguration." lastActiveDate: "Zuletzt verwendet am" statusbar: "Statusleiste" pleaseSelect: "Wähle eine Option" @@ -1144,7 +1144,7 @@ preventAiLearning: "Verwendung in machinellem Lernen (Generative bzw. Prediktive preventAiLearningDescription: "Fordert Crawler auf, gepostetes Text- oder Bildmaterial usw. nicht in Datensätzen für maschinelles Lernen (Generative bzw. Prediktive AI/KI) zu verwenden. Dies wird durch das Hinzufügen einer \"noai\"-Flag in der HTML-Antwort des jeweiligen Inhalts erreicht. Da diese Flag jedoch ignoriert werden kann, ist eine vollständige Verhinderung hierdurch nicht möglich." options: "Optionen" specifyUser: "Spezifischer Benutzer" -lookupConfirm: "Zustimmen?" +lookupConfirm: "Bist du sicher, dass du das nachschlagen möchtest?" openTagPageConfirm: "Hashtag Seite wirklich öffnen?" specifyHost: "Host" failedToPreviewUrl: "Vorschau nicht anzeigbar" @@ -1259,7 +1259,7 @@ replaying: "Aufzeichnung" endReplay: "Aufzeichnung verlassen" copyReplayData: "Aufzeichnung kopieren" ranking: "Rangliste" -lastNDays: "Letzten {n} Tage" +lastNDays: "Letzte {n} Tage" backToTitle: "Zurück zum Startbildschirm" hemisphere: "Hemisphäre" withSensitive: "Zeige \"sensitive Inhalte\" an" @@ -1306,17 +1306,151 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "Logge dich ein, um weitere Inhal lockdown: "Sperren" pleaseSelectAccount: "Bitte Konto auswählen" availableRoles: "Verfügbare Rollen" +acknowledgeNotesAndEnable: "Schalten Sie dies erst ein, wenn Sie die Vorsichtsmaßnahmen verstanden haben." federationSpecified: "Dieser Server arbeitet mit Whitelist-Föderation. Er kann nicht mit anderen als den vom Administrator angegebenen Servern interagieren." federationDisabled: "Föderation ist auf diesem Server deaktiviert. Es ist nicht möglich, mit Benutzern auf anderen Servern zu interagieren." +confirmOnReact: "Reagieren bestätigen" +reactAreYouSure: "Willst du eine \"{emoji}\"-Reaktion hinzufügen?" +markAsSensitiveConfirm: "Möchtest du dieses Medium als sensibel kennzeichnen?" +unmarkAsSensitiveConfirm: "Möchtest du die Kennzeichnung dieses Mediums als sensibel aufheben?" +preferences: "Einstellungen" +accessibility: "Eingabehilfe" +preferencesProfile: "Einstellungsprofil" +copyPreferenceId: "Kopiere die Einstellungs-ID" +resetToDefaultValue: "Auf Standard zurücksetzen" +overrideByAccount: "Überschreibung durch das Konto" +untitled: "Unbenannt" +noName: "Kein Name" +skip: "Überspringen" +restore: "Wiederherstellen" +syncBetweenDevices: "Zwischen Geräten synchronisieren" +preferenceSyncConflictTitle: "Der konfigurierte Wert ist auf dem Server bereits vorhanden." +preferenceSyncConflictText: "Die Einstellungen mit aktivierter Synchronisierung werden ihre Werte auf dem Server speichern. Es gibt jedoch bereits Werte auf dem Server. Welche Einstellungswerte sollen überschrieben werden?" +preferenceSyncConflictChoiceServer: "Konfigurierte Werte auf dem Server" +preferenceSyncConflictChoiceDevice: "Konfigurierte Werte auf dem Gerät" +preferenceSyncConflictChoiceCancel: "Einrichten der Synchronisierung abbrechen" +paste: "Einfügen" +emojiPalette: "Emoji-Palette" +postForm: "Notizfenster" +textCount: "Zeichenanzahl" +information: "Über" +chat: "Chat" +migrateOldSettings: "Alte Client-Einstellungen migrieren" +migrateOldSettings_description: "Dies sollte normalerweise automatisch geschehen, aber wenn die Migration aus irgendeinem Grund nicht erfolgreich war, kannst du den Migrationsprozess selbst manuell auslösen. Die aktuellen Konfigurationsinformationen werden dabei überschrieben." +compress: "Komprimieren" +right: "Rechts" +bottom: "Unten" +top: "Oben" +embed: "Einbetten" +settingsMigrating: "Ihre Einstellungen werden gerade migriert, Bitte warten Sie einen Moment... (Sie können die Einstellungen später auch manuell migrieren, indem Sie zu Einstellungen → Sonstiges → Alte Einstellungen migrieren gehen)" +readonly: "Nur Lesezugriff" +goToDeck: "Zurück zum Deck" +federationJobs: "Föderation Jobs" +driveAboutTip: "In Drive sehen Sie eine Liste der Dateien, die Sie in der Vergangenheit hochgeladen haben.
\nSie können diese Dateien wiederverwenden um sie zu beispiel an Notizen anzuhängen, oder sie können Dateien vorab hochzuladen, um sie später zu versenden!
\nWenn Sie eine Datei löschen, verschwindet sie auch von allen Stellen, an denen Sie sie verwendet haben (Notizen, Seiten, Avatare, Banner usw.).
\nSie können auch Ordner erstellen, um sie zu organisieren." +_chat: + noMessagesYet: "Noch keine Nachrichten" + newMessage: "Neue Nachricht" + individualChat: "Privater Chat" + individualChat_description: "Führe einen privaten Chat mit einer anderen Person." + roomChat: "Chatraum" + roomChat_description: "Ein Chat-Raum, an dem mehrere Personen teilnehmen können.\nDu kannst auch Personen einladen, die keine privaten Chats zulassen, wenn sie die Einladung annehmen." + createRoom: "Raum erstellen" + inviteUserToChat: "Lade Benutzer ein, um mit dem Chatten zu beginnen" + yourRooms: "Erstellte Räume" + joiningRooms: "Raum beitreten" + invitations: "Einladen" + noInvitations: "Keine Einladungen" + history: "Verlauf" + noHistory: "Kein Verlauf gefunden" + noRooms: "Keine Räume gefunden" + inviteUser: "Benutzer einladen" + sentInvitations: "Verschickte Einladungen" + join: "Beitreten" + ignore: "Ignorieren" + leave: "Raum verlassen" + members: "Mitglieder" + searchMessages: "Nachrichten suchen" + home: "Startseite" + send: "Senden" + newline: "Neue Zeile" + muteThisRoom: "Raum stummschalten" + deleteRoom: "Raum löschen" + chatNotAvailableForThisAccountOrServer: "Der Chat ist auf diesem Server oder für dieses Konto nicht aktiviert." + chatIsReadOnlyForThisAccountOrServer: "Der Chat ist auf dieser Instanz oder diesem Konto nur zum Lesen freigegeben. Es ist nicht möglich, neue Nachrichten zu schreiben oder Chaträume zu erstellen oder zu betreten." + chatNotAvailableInOtherAccount: "Die Chatfunktion wurde vom anderen Benutzer deaktiviert." + cannotChatWithTheUser: "Starten eines Chats mit diesem Benutzer nicht möglich" + cannotChatWithTheUser_description: "Der Chat ist entweder nicht verfügbar oder die andere Seite hat den Chat nicht aktiviert." + chatWithThisUser: "Mit dem Benutzer chatten" + thisUserAllowsChatOnlyFromFollowers: "Dieser Benutzer nimmt nur Chats von Followern an." + thisUserAllowsChatOnlyFromFollowing: "Dieser Benutzer nimmt nur Chats von Benutzern an, denen er folgt." + thisUserAllowsChatOnlyFromMutualFollowing: "Dieser Benutzer akzeptiert nur Chats von Benutzern, die sich gegenseitig folgen." + thisUserNotAllowedChatAnyone: "Dieser Benutzer nimmt keine Chats von anderen Benutzern an." + chatAllowedUsers: "Wem das Chatten erlaubt werden soll" + chatAllowedUsers_note: "Du kannst unabhängig von dieser Einstellung mit allen Personen chatten, denen du eine Chat-Nachricht gesendet hast." + _chatAllowedUsers: + everyone: "Jeder" + followers: "Nur deine Follower" + following: "Nur Benutzer, denen du folgst" + mutual: "Nur Benutzer, die sich gegenseitig folgen" + none: "Niemand" +_emojiPalette: + palettes: "Palette" + enableSyncBetweenDevicesForPalettes: "Synchronisierung der Paletten zwischen Geräten aktivieren" + paletteForMain: "Hauptpalette" + paletteForReaction: "Reaktions-Palette" +_settings: + driveBanner: "Du kannst den Drive verwalten und konfigurieren, die Auslastung überprüfen und Einstellungen für das Hochladen von Dateien vornehmen." + pluginBanner: "Du kannst die Funktionen des Clients mit Plugins erweitern. Plugins können installiert, individuell konfiguriert und verwaltet werden." + notificationsBanner: "Sie können die Arten und den Umfang der Benachrichtigungen vom Server und der Push- Mitteilungen konfigurieren." + api: "API" + webhook: "Webhook" + serviceConnection: "Integrierte Dienste" + serviceConnectionBanner: "Du kannst Zugriffstoken und Webhooks für die Integration mit externen Anwendungen und Diensten verwalten und konfigurieren." + accountData: "Kontodaten" + accountDataBanner: "Export/Import und Verwaltung von Kontodatenarchiven." + muteAndBlockBanner: "Du kannst Einstellungen konfigurieren und verwalten, um Inhalte auszublenden und Aktionen für bestimmte Benutzer zu beschränken." + accessibilityBanner: "Die Clients können personalisiert und für eine optimale Nutzung im Hinblick auf ihre Darstellung und ihr Verhalten eingerichtet werden." + privacyBanner: "Du kannst Einstellungen für die Privatsphäre deines Kontos vornehmen, z. B. inwieweit Inhalte veröffentlicht werden, wie leicht sie zu finden sind und ob Follower genehmigt werden müssen." + securityBanner: "Du kannst Einstellungen für die Kontosicherheit konfigurieren, z. B. Passwörter, Anmeldemethoden, Authentifizierungs-Apps und Passkeys." + preferencesBanner: "Sie können das Gesamtverhalten des Clients nach Ihren Wünschen konfigurieren." + appearanceBanner: "Du kannst das Erscheinungsbild und die Anzeigeeinstellungen für den Client nach deinen Wünschen konfigurieren." + soundsBanner: "Du kannst die Einstellungen für die Wiedergabe von Klängen im Client konfigurieren." + timelineAndNote: "Chroniken und Notizen" + makeEveryTextElementsSelectable: "Alle Textelemente auswählbar machen" + makeEveryTextElementsSelectable_description: "Die Aktivierung kann in manchen Situationen die Benutzerfreundlichkeit beeinträchtigen." + useStickyIcons: "Icons beim Scrollen folgen lassen" + showNavbarSubButtons: "Unterschaltflächen in der Navigationsleiste anzeigen" + ifOn: "Wenn eingeschaltet" + ifOff: "Wenn ausgeschaltet" + enableSyncThemesBetweenDevices: "Synchronisierung von installierten Themen auf verschiedenen Endgeräten" + _chat: + showSenderName: "Name des Absenders anzeigen" + sendOnEnter: "Eingabetaste sendet Nachricht" +_preferencesProfile: + profileName: "Profilname" + profileNameDescription: "Lege einen Namen fest, der dieses Gerät identifiziert." + profileNameDescription2: "Beispiel: \"Haupt-PC\", \"Smartphone\"" +_preferencesBackup: + autoBackup: "Automatische Sicherung" + restoreFromBackup: "Wiederherstellen aus der Sicherung" + noBackupsFoundTitle: "Keine Sicherungen gefunden" + noBackupsFoundDescription: "Es wurden keine automatisch erstellten Sicherungen gefunden, aber wenn du eine Sicherungsdatei manuell gespeichert hast, kannst du diese importieren und wiederherstellen." + selectBackupToRestore: "Wähle die wiederherzustellende Sicherung" + youNeedToNameYourProfileToEnableAutoBackup: "Um die automatische Sicherung zu aktivieren, müssen Profilnamen festgelegt werden." + autoPreferencesBackupIsNotEnabledForThisDevice: "Die automatische Sicherung der Einstellungen ist auf diesem Gerät nicht aktiviert." + backupFound: "Konfigurationssicherung gefunden." _accountSettings: requireSigninToViewContents: "Anmeldung erfordern, um Inhalte anzuzeigen" requireSigninToViewContentsDescription1: "Erfordere eine Anmeldung, um alle Notizen und andere Inhalte anzuzeigen, die du erstellt hast. Dadurch wird verhindert, dass Crawler deine Informationen sammeln." requireSigninToViewContentsDescription2: "Der Inhalt wird nicht in URL-Vorschauen (OGP), eingebettet in Webseiten oder auf Servern, die keine Zitate unterstützen, angezeigt." requireSigninToViewContentsDescription3: "Diese Einschränkungen gelten möglicherweise nicht für föderierte Inhalte von anderen Servern." makeNotesFollowersOnlyBefore: "Macht frühere Notizen nur für Follower sichtbar" + makeNotesFollowersOnlyBeforeDescription: "Solange diese Funktion aktiviert ist, sind Notizen, die nach dem eingestellten Datum und der eingestellten Zeit liegen oder die eingestellte Zeit abgelaufen ist, nur für Follower sichtbar. Bei Deaktivierung wird auch der öffentliche Status der Notiz wiederhergestellt." makeNotesHiddenBefore: "Frühere Notizen privat machen" makeNotesHiddenBeforeDescription: "" mayNotEffectForFederatedNotes: "Dies hat möglicherweise keine Auswirkungen auf Notizen, die an andere Server föderiert werden." + mayNotEffectSomeSituations: "Diese Einschränkungen sind vereinfacht. Sie gelten möglicherweise nicht in allen Situationen, z. B. bei der Anzeige auf einem fremden Server oder während der Moderation." + notesHavePassedSpecifiedPeriod: "Notizen die nach der folgenden Zeit veröffentlicht worden" notesOlderThanSpecifiedDateAndTime: "Notizen vor einem bestimmtem Datum und Uhrzeit" _abuseUserReport: forward: "Weiterleiten" @@ -1324,11 +1458,16 @@ _abuseUserReport: resolve: "lösen" accept: "Akzeptieren" reject: "Ablehnen" + resolveTutorial: "Wenn der Inhalt der Meldung rechtmäßig ist, wähle „Akzeptieren“, um sie als gelöst zu markieren.\nWenn der Inhalt der Meldung unzulässig ist, wähle „Ablehnen“, um sie zu ignorieren." _delivery: + status: "Auslieferungsstatus" stop: "Gesperrt" + resume: "Zustellung wieder fortsetzen" _type: none: "Wird veröffentlicht" manuallySuspended: "Manuell gesperrt" + goneSuspended: "Gesperrt wegen Löschung des Servers" + autoSuspendedForNotResponding: "Gesperrt, weil der Server nicht antwortet" _bubbleGame: howToPlay: "Wie man spielt" hold: "Halten" @@ -1338,6 +1477,8 @@ _bubbleGame: highScore: "Höchstpunktzahl" maxChain: "Maximale Anzahl an Verkettungen" yen: "{yen} Yen" + estimatedQty: "{qty} Stück" + scoreSweets: "{onigiriQtyWithUnit} Onigiri" _howToPlay: section1: "Passe die Position an und lasse das Objekt in das Spielfeld fallen." section2: "Wenn sich zwei Objekte der gleichen Art berühren, verwandeln sie sich in ein anderes Objekt und du bekommst Punkte." @@ -1383,6 +1524,9 @@ _initialTutorial: title: "Was sind Notizen?" description: "Beiträge auf Misskey heißen \"Notizen\". Notizen werden chronologisch in der Chronik angeordnet und in Echtzeit aktualisiert." reply: "Klicke auf diesen Button, um auf eine Nachricht zu antworten. Es ist auch möglich, auf Antworten zu antworten und die Unterhaltung wie einen Thread fortzusetzen." + renote: "Du kannst diese Notiz in deiner eigenen Chronik teilen. Du kannst sie auch mit deinen Kommentaren zitieren." + reaction: "Du kannst der Notiz Reaktionen hinzufügen. Weitere Einzelheiten werden auf der nächsten Seite erläutert." + menu: "Du kannst Details zu Notizen anzeigen, Links kopieren und verschiedene andere Aktionen durchführen." _reaction: title: "Was sind Reaktionen?" description: "Auf Notizen kann mit verschiedenen Emojis reagiert werden. Reaktionen ermöglichen es dir, Nuancen auszudrücken, die mit einem einfachen „Gefällt mir“ vielleicht nicht ausgedrückt werden können." @@ -1392,22 +1536,38 @@ _initialTutorial: reactDone: "Du kannst eine Reaktion zurücknehmen, indem du auf den '-' Button drückst." _timeline: title: "So funktionieren die Chroniken" + description1: "Misskey stellt mehrere Chroniken bereit (einige können je nach den Richtlinien des Servers nicht verfügbar sein)." home: "Du kannst Beiträge von den Konten sehen, denen du folgst." local: "Du kannst Beiträge aller Benutzer auf diesem Server sehen." social: "Notizen von der Startseite und der lokalen Chronik werden angezeigt." global: "Du kannst Notizen von allen föderierten Servern sehen." description2: "Du kannst jederzeit am oberen Rand des Bildschirms zwischen den jeweiligen Chroniken wechseln." + description3: "Darüber hinaus gibt es Listen-Chroniken und Kanal-Chroniken. Weitere Einzelheiten findest du unter {link}." _postNote: + title: "Optionen bei Abschicken einer Notiz" + description1: "Wenn du eine Notiz auf Misskey veröffentlichst, stehen dir verschiedene Optionen zur Verfügung. Die Oberfläche sieht folgendermaßen aus." _visibility: description: "Du kannst einschränken, wer deine Notiz sehen kann." public: "Deine Notiz wird für alle Nutzer sichtbar sein." + home: "Nur auf der Startseite sichtbar. Kann von Followern, Profilbesuchern und durch Renotes gesehen werden." + followers: "Nur für Follower sichtbar. Nur Follower können es sehen und niemand sonst, und es kann nicht von anderen gerenoted werden." + direct: "Die Notiz wird nur für den angegebenen Benutzer veröffentlicht und der Empfänger wird benachrichtigt. Kann anstelle von Direktnachrichten verwendet werden." doNotSendConfidencialOnDirect1: "Sei vorsichtig, wenn du sensible Informationen verschickst!" + doNotSendConfidencialOnDirect2: "Die Administratoren des Servers können den Inhalt der Notiz sehen. Sei vorsichtig mit sensiblen Informationen, wenn du Direktnachrichten an Benutzer auf nicht vertrauenswürdigen Servern sendest." + localOnly: "Wenn du eine Notiz mit dieser Einstellung veröffentlichst, wird sie nicht an andere Server weitergeleitet. Benutzer auf anderen Servern können diese Notizen nicht direkt sehen, unabhängig von den obigen Anzeigeeinstellungen." _cw: title: "Inhaltswarnung" + description: "Anstelle des Textes wird das angezeigt, was du im Abschnitt „Anmerkungen“ angibst. Drücke auf „Inhalt anzeigen“, um den vollständigen Text zu sehen." _exampleNote: + cw: "Das wird dich bestimmt hungrig machen!" note: "Ich hatte gerade einen Donut mit Schokoladenüberzug 🍩😋" + useCases: "Dient zur Kennzeichnung von Notizen, wie sie in den Serverrichtlinien vorgeschrieben sind, oder zur eigenen Festlegung von Spoiler-Beiträgen oder sensiblem Text." _howToMakeAttachmentsSensitive: + title: "Wie markiert man Anhänge als sensibel?" + description: "Markiere Anhänge als sensibel, die aufgrund von den Serverregeln nicht sichtbar sein sollen." tryThisFile: "Versuche, das angehängte Bild als sensibel zu markieren!" + _exampleNote: + note: "Ups, ich habe es vergeigt, den Natto-Deckel zu öffnen..." method: "Um einen Anhang als sensibel zu kennzeichnen, klicke auf das Vorschaubild der Datei, um das Menü zu öffnen, und klicke auf „Als sensibel markieren“." sensitiveSucceeded: "Wenn du Dateien anhängst, stelle bitte die Sensibilität entsprechend der Serverrichtlinien ein." doItToContinue: "Markiere die angehängte Datei als sensibel, um fortzufahren." @@ -1415,7 +1575,9 @@ _initialTutorial: title: "Du hast das Tutorial abgeschlossen! 🎉" description: "Die hier beschriebenen Funktionen sind nur ein kleiner Teil dessen, was Misskey zu bieten hat; um mehr darüber zu erfahren, wie du Misskey benutzen kannst, besuche bitte {link}." _timelineDescription: + home: "In der Startseiten-Chronik kannst du Notizen von Konten sehen, denen du folgst." local: "In der lokalen Chronik siehst du Notizen von allen Benutzern auf diesem Server." + social: "Die soziale Chronik zeigt Notizen von der Startseite und der lokalen Chronik." global: "In der globalen Chronik siehst du Notizen von allen föderierten Servern." _serverRules: description: "Eine Reihe von Regeln, die vor der Registrierung angezeigt werden. Eine Zusammenfassung der Nutzungsbedingungen anzuzeigen ist empfohlen." @@ -1431,6 +1593,10 @@ _serverSettings: fanoutTimelineDescription: "Ist diese Option aktiviert, kann eine erhebliche Verbesserung im Abrufen von Chroniken und eine Reduzierung der Datenbankbelastung erzielt werden, im Gegenzug zu einer Steigerung in der Speichernutzung von Redis. Bei geringem Serverspeicher oder Serverinstabilität kann diese Option deaktiviert werden." fanoutTimelineDbFallback: "Auf die Datenbank zurückfallen" fanoutTimelineDbFallbackDescription: "Ist diese Option aktiviert, wird die Chronik auf zusätzliche Abfragen in der Datenbank zurückgreifen, wenn sich die Chronik nicht im Cache befindet. Eine Deaktivierung führt zu geringerer Serverlast, aber schränkt den Zeitraum der abrufbaren Chronik ein. " + reactionsBufferingDescription: "Wenn diese Option aktiviert ist, kann sie die Leistung beim Erstellen von Reaktionen erheblich verbessern und die Belastung der Datenbank verringern. Allerdings steigt die Speichernutzung von Redis." + inquiryUrl: "Kontakt-URL" + inquiryUrlDescription: "Gib eine URL für das Kontaktformular der Serverbetreiber oder eine Webseite an, die Kontaktinformationen enthält." + openRegistration: "Registrierung von Konten aktivieren" openRegistrationWarning: "Das Aktivieren von Registrierungen ist riskant. Es wird empfohlen, sie nur dann zu aktivieren, wenn der Server ständig überwacht wird und im Falle eines Problems sofort reagiert werden kann." thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Wenn über einen bestimmten Zeitraum keine Moderatorenaktivität festgestellt wird, wird diese Einstellung automatisch deaktiviert, um Spam zu verhindern." _accountMigration: @@ -1695,8 +1861,11 @@ _achievements: description: "Tutorial abgeschlossen" _bubbleGameExplodingHead: title: "🤯" + description: "Das größte Objekt im Bubble Game" _bubbleGameDoubleExplodingHead: title: "Doppel🤯" + description: "Zwei der größten Objekte im Bubble Game zur gleichen Zeit" + flavor: "Eine Lunchbox kann man auch mit etwas mehr 🤯 🤯 füllen" _role: new: "Rolle erstellen" edit: "Rolle bearbeiten" @@ -1726,6 +1895,8 @@ _role: descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Chronik dieser Rolle, sowie eine Liste der Benutzer mit dieser Rolle, frei zugänglich." displayOrder: "Position" descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position." + preserveAssignmentOnMoveAccount: "Rolle übertragbar machen" + preserveAssignmentOnMoveAccount_description: "Wenn diese Option aktiviert ist, wird diese Rolle bei der Migration mit übertragen." canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen" descriptionOfCanEditMembersByModerator: "Wenn aktiviert, so können Moderatoren und Adminstratoren anderen Benutzern diese Rolle zuweisen bzw. diese Zuweisung aufheben. Wenn deaktiviert, so ist es nur Administratoren möglich, Zuweisungen dieser Rolle zu verwalten." priority: "Priorität" @@ -1745,7 +1916,9 @@ _role: canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten" canManageAvatarDecorations: "Profilbilddekorationen verwalten" driveCapacity: "Drive-Kapazität" + maxFileSize: "Maximale Dateigröße, die hochgeladen werden kann" alwaysMarkNsfw: "Dateien immer als NSFW markieren" + canUpdateBioMedia: "Kann ein Profil- oder ein Bannerbild bearbeiten" pinMax: "Maximale Anzahl an angehefteten Notizen" antennaMax: "Maximale Anzahl an Antennen" wordMuteMax: "Maximale Zeichenlänge für Wortstummschaltungen" @@ -1761,11 +1934,20 @@ _role: canUseTranslator: "Verwendung des Übersetzers" avatarDecorationLimit: "Maximale Anzahl an Profilbilddekorationen, die angebracht werden können" canImportAntennas: "Importieren von Antennen erlauben" + canImportBlocking: "Importieren von Blockierungen zulassen" + canImportFollowing: "Importieren von Gefolgten zulassen" + canImportMuting: "Importieren von Stummgeschalteten zulassen" + canImportUserLists: "Importieren von Listen erlauben" + chatAvailability: "Chatten erlauben" _condition: + roleAssignedTo: "Manuellen Rollen zugewiesen" isLocal: "Lokaler Benutzer" isRemote: "Benutzer fremder Instanz" isCat: "Katzen-Benutzer" isBot: "Bot-Benutzer" + isSuspended: "Gesperrter Benutzer" + isLocked: "Private Konten" + isExplorable: "Benutzer, die ihr Konto im \"Erkunden\"-Bereich sichtbar machen" createdLessThan: "Kontoerstellung liegt weniger als X zurück" createdMoreThan: "Kontoerstellung liegt mehr als X zurück" followersLessThanOrEq: "Hat X oder weniger Follower" @@ -1835,6 +2017,7 @@ _plugin: installWarn: "Installiere bitte nur vertrauenswürdige Plugins." manage: "Plugins verwalten" viewSource: "Quelltext anzeigen" + viewLog: "Protokoll anzeigen" _preferencesBackups: list: "Erstellte Backups" saveNew: "Neu erstellen" @@ -1864,6 +2047,8 @@ _aboutMisskey: contributors: "Hauptmitwirkende" allContributors: "Alle Mitwirkenden" source: "Quellcode" + original: "Original" + thisIsModifiedVersion: "{name} verwendet eine modifizierte Version des ursprünglichen Misskey." translation: "Misskey übersetzen" donate: "An Misskey spenden" morePatrons: "Wir schätzen ebenso die Unterstützung vieler anderer hier nicht gelisteter Personen sehr. Danke! 🥰" @@ -1917,6 +2102,7 @@ _theme: installed: "{name} wurde installiert" installedThemes: "Installierte Farbschemata" builtinThemes: "Eingebaute Farbschemata" + instanceTheme: "Server-Thema" alreadyInstalled: "Dieses Farbschema ist bereits installiert" invalid: "Der Code dieses Farbschemas ist ungültig" make: "Farbschema erstellen" @@ -1949,7 +2135,6 @@ _theme: header: "Kopfzeile" navBg: "Hintergrund der Seitenleiste" navFg: "Text der Seitenleiste" - navHoverFg: "Text der Seitenleiste (Mouseover)" navActive: "Text der Seitenleiste (Aktiv)" navIndicator: "Indikator der Seitenleiste" link: "Link" @@ -1972,23 +2157,23 @@ _theme: buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)" inputBorder: "Rahmen von Eingabefeldern" driveFolderBg: "Hintergrund von Drive-Ordnern" - wallpaperOverlay: "Hintergrundbild-Overlay" badge: "Wappen" messageBg: "Hintergrund von Chats" - accentDarken: "Akzent (Verdunkelt)" - accentLighten: "Akzent (Erhellt)" fgHighlighted: "Hervorgehobener Text" _sfx: note: "Notizen" noteMy: "Meine Notizen" notification: "Benachrichtigungen" reaction: "Auswählen einer Reaktion" + chatMessage: "Chat-Nachrichten" _soundSettings: driveFile: "Audiodatei aus dem Drive verwenden" driveFileWarn: "Wähle eine Audiodatei aus dem Drive" driveFileTypeWarn: "Diese Datei wird nicht unterstützt" driveFileTypeWarnDescription: "Bitte wähle eine Audiodatei" driveFileDurationWarn: "Audio zu lang." + driveFileDurationWarnDescription: "Lange Töne kann die Verwendung von Misskey stören. Trotzdem fortfahren?" + driveFileError: "Audio konnte nicht geladen werden. Bitte ändere die Einstellung." _ago: future: "Zukunft" justNow: "Gerade eben" @@ -2000,6 +2185,14 @@ _ago: monthsAgo: "vor {n} Monat(en)" yearsAgo: "vor {n} Jahr(en)" invalid: "Ungültig" +_timeIn: + seconds: "In {n}s" + minutes: "In {n} Min." + hours: "In {n} Std." + days: "In {n} Tagen" + weeks: "In {n} Wochen" + months: "In {n} Monaten" + years: "In {n} Jahren" _time: second: "Sekunde(n)" minute: "Minute(n)" @@ -2033,6 +2226,7 @@ _2fa: backupCodesDescription: "Verwende diese Codes, falls du nicht mehr auf deine App zur Zweifaktorauthentifizierung zugreifen kannst. Jeder Code kann nur einmal verwendet werden. Bewahre sie an einem sicheren Ort auf." backupCodeUsedWarning: "Ein Backup-Code wurde verwendet. Falls du den Zugriff zu deiner Zweifaktorauthentifizierungsapp verloren hast, konfiguriere diese bitte möglichst bald erneut." backupCodesExhaustedWarning: "Alle Backup-Codes wurden verwendet. Falls du den Zugang zu deiner Zweifaktorauthentifizierungsapp verlierst, wirst du dich nicht mehr in dieses Konto einloggen können. Bitte konfiguriere diese App erneut." + moreDetailedGuideHere: "Hier ist eine ausführliche Anleitung" _permissions: "read:account": "Deine Benutzerkontoinformationen lesen" "write:account": "Deine Benutzerkontoinformationen bearbeiten" @@ -2070,6 +2264,7 @@ _permissions: "write:flash": "Deine Plays bearbeiten oder löschen" "read:flash-likes": "Liste der Plays, die mir gefallen, lesen" "write:flash-likes": "Liste der Plays, die mir gefallen, bearbeiten" + "read:admin:abuse-user-reports": "Meldungen von Benutzern ansehen" "write:admin:delete-account": "Benutzerkonto löschen" "write:admin:delete-all-files-of-a-user": "Alle Dateien eines Benutzers löschen" "read:admin:index-stats": "Statistiken zu Datenbankindizes einsehen" @@ -2077,10 +2272,17 @@ _permissions: "read:admin:user-ips": "IP-Adressen von Benutzern anzeigen" "read:admin:meta": "Metadaten der Instanz einsehen" "write:admin:reset-password": "Benutzerpasswort zurücksetzen" + "write:admin:resolve-abuse-user-report": "Meldungen von Benutzern lösen" "write:admin:send-email": "E-Mail versenden" "read:admin:server-info": "Serverinformationen anzeigen" "read:admin:show-moderation-log": "Moderationsprotokoll einsehen" "read:admin:show-user": "Private Benutzerinformationen einsehen" + "write:admin:suspend-user": "Benutzer sperren" + "write:admin:unset-user-avatar": "Benutzer-Profilbild entfernen" + "write:admin:unset-user-banner": "Benutzer-Banner entfernen" + "write:admin:unsuspend-user": "Benutzer entsperren" + "write:admin:meta": "Metadaten der Instanz verwalten" + "write:admin:user-note": "Moderationsvermerke verwalten" "write:admin:roles": "Rollen verwalten" "read:admin:roles": "Rollen anzeigen" "write:admin:relays": "Relays verwalten" @@ -2091,12 +2293,27 @@ _permissions: "read:admin:announcements": "Ankündigungen einsehen" "write:admin:avatar-decorations": "Kann Avatar-Dekorationen verwalten" "read:admin:avatar-decorations": "Avatar-Dekorationen ansehen" + "write:admin:federation": "Informationen über Föderationen bearbeiten oder löschen" "write:admin:account": "Benutzerkonten verwalten" "read:admin:account": "Benutzerkonten anzeigen" "write:admin:emoji": "Emojis verwalten" "read:admin:emoji": "Emojis anzeigen" "write:admin:queue": "Job-Warteschlange verwalten" "read:admin:queue": "Job-Warteschlange anzeigen" + "write:admin:promo": "Moderationsnotiz hinzufügen" + "write:admin:drive": "Benutzer-Drive verwalten" + "read:admin:drive": "Benutzer-Drive ansehen" + "read:admin:stream": "Verwendung der Websocket-API für Administratoren" + "write:admin:ad": "Werbung verwalten" + "read:admin:ad": "Werbung ansehen" + "write:invite-codes": "Einladungscodes erstellen" + "read:invite-codes": "Einladungscodes anzeigen" + "write:clip-favorite": "Clip-Likes bearbeiten oder löschen" + "read:clip-favorite": "Clip-Likes ansehen" + "read:federation": "Informationen zur Föderation einsehen" + "write:report-abuse": "Verstöße melden" + "write:chat": "Chats bedienen" + "read:chat": "Chats durchsuchen" _auth: shareAccessTitle: "Verteilung von App-Berechtigungen" shareAccess: "Möchtest du „{name}“ authorisieren, auf dieses Benutzerkonto zugreifen zu können?" @@ -2105,8 +2322,11 @@ _auth: permissionAsk: "Diese Anwendung fordert folgende Berechtigungen" pleaseGoBack: "Bitte kehre zur Anwendung zurück" callback: "Es wird zur Anwendung zurückgekehrt" + accepted: "Zugriff gewährt" denied: "Zugriff verweigert" + scopeUser: "Als folgender Benutzer agieren" pleaseLogin: "Bitte logge dich ein, um Apps zu authorisieren." + byClickingYouWillBeRedirectedToThisUrl: "Wenn der Zugang gewährt wird, wirst du automatisch zu folgender URL weitergeleitet" _antennaSources: all: "Alle Notizen" homeTimeline: "Notizen von Benutzern, denen gefolgt wird" @@ -2152,6 +2372,7 @@ _widgets: chooseList: "Liste auswählen" clicker: "Klickzähler" birthdayFollowings: "Nutzer, die heute Geburtstag haben" + chat: "Chat" _cw: hide: "Inhalt verbergen" show: "Inhalt anzeigen" @@ -2215,7 +2436,9 @@ _profile: changeBanner: "Banner ändern" verifiedLinkDescription: "Gibst du hier eine URL ein, die einen Link zu deinem Profile enthält, wird neben diesem Feld ein Icon zur Besitzbestätigung angezeigt." avatarDecorationMax: "Du kannst bis zu {max} Dekorationen hinzufügen." + followedMessage: "Nachricht, wenn dir jemand folgt" followedMessageDescription: "Du kannst eine kurze Nachricht festlegen, die dem Empfänger angezeigt wird, wenn er dir folgt." + followedMessageDescriptionForLockedAccount: "Wenn Folgeanfragen deine Genehmigung brauchen, wird dies beim Genehmigen einer Anfrage angezeigt." _exportOrImport: allNotes: "Alle Notizen" favoritedNotes: "Als Favorit markierte Notizen" @@ -2273,6 +2496,7 @@ _play: title: "Titel" script: "Skript" summary: "Beschreibung" + visibilityDescription: "Wenn du die Sichtbarkeit auf Privat stellst, wird der Play nicht auf deinem Profil sichtbar sein, aber jeder, der die URL hat, kann ihn trotzdem aufrufen." _pages: newPage: "Seite erstellen" editPage: "Seite bearbeiten" @@ -2304,6 +2528,7 @@ _pages: eyeCatchingImageSet: "Vorschaubild festlegen" eyeCatchingImageRemove: "Vorschaubild entfernen" chooseBlock: "Block hinzufügen" + enterSectionTitle: "Titel des Abschnitts eingeben" selectType: "Typ auswählen" contentBlocks: "Inhalt" inputBlocks: "Eingabe" @@ -2314,6 +2539,8 @@ _pages: section: "Abschnitt" image: "Bild" button: "Knopf" + dynamic: "Dynamische Bausteine" + dynamicDescription: "Dieser Baustein wurde abgeschafft. Bitte verwende von nun an {play}." note: "Eingebettete Notiz" _note: id: "Notiz-ID" @@ -2336,6 +2563,7 @@ _notification: newNote: "Neue Notiz" unreadAntennaNote: "Antenne {name}" roleAssigned: "Rolle zugewiesen" + chatRoomInvitationReceived: "Du wurdest in einen Chatraum eingeladen" emptyPushNotificationMessage: "Push-Benachrichtigungen wurden aktualisiert" achievementEarned: "Errungenschaft freigeschaltet" testNotification: "Testbenachrichtigung" @@ -2343,9 +2571,14 @@ _notification: sendTestNotification: "Testbenachrichtigung senden" notificationWillBeDisplayedLikeThis: "Benachrichtigungen sehen so aus" reactedBySomeUsers: "{n} Benutzer haben eine Reaktion geschickt" + likedBySomeUsers: "{n} Benutzer mochten deine Notiz" renotedBySomeUsers: "Renote von {n} Benutzern" followedBySomeUsers: "Von {n} Benutzern gefolgt" + flushNotification: "Benachrichtigungen löschen" + exportOfXCompleted: "Der Export von {x} ist abgeschlossen" login: "Neue Anmeldung erfolgt" + createToken: "Ein Zugangstoken wurde erstellt" + createTokenDescription: "Wenn Sie keine Ahnung haben, löschen Sie das Zugriffstoken über \"{text}\"" _types: all: "Alle" note: "Neue Notizen" @@ -2359,8 +2592,12 @@ _notification: receiveFollowRequest: "Erhaltene Follow-Anfragen" followRequestAccepted: "Akzeptierte Follow-Anfragen" roleAssigned: "Rolle zugewiesen" + chatRoomInvitationReceived: "Einladungen zum Chatraum" achievementEarned: "Errungenschaft freigeschaltet" - login: "Anmelden" + exportCompleted: "Der Export ist abgeschlossen" + login: "Anmeldung" + createToken: "Erstellung von Zugriffstokens" + test: "Test-Benachrichtigungen" app: "Benachrichtigungen von Apps" _actions: followBack: "folgt dir nun auch" @@ -2369,7 +2606,11 @@ _notification: _deck: alwaysShowMainColumn: "Hauptspalte immer zeigen" columnAlign: "Spaltenausrichtung" + columnGap: "Spaltenabstand" + deckMenuPosition: "Position des Deck-Menüs" + navbarPosition: "Position der Navigationsleiste" addColumn: "Spalte hinzufügen" + newNoteNotificationSettings: "Benachrichtigungseinstellungen für neue Notizen" configureColumn: "Spalteneinstellungen" swapLeft: "Mit linker Spalte tauschen" swapRight: "Mit rechter Spalte tauschen" @@ -2386,6 +2627,7 @@ _deck: useSimpleUiForNonRootPages: "Simple Benutzeroberfläche für navigierte Seiten verwenden" usedAsMinWidthWhenFlexible: "Ist \"Automatische Breitenanpassung\" aktiviert, wird hierfür die minimale Breite verwendet" flexible: "Automatische Breitenanpassung" + enableSyncBetweenDevicesForProfiles: "Aktivieren der Synchronisierung von Profilinformationen zwischen Geräten" _columns: main: "Hauptspalte" widgets: "Widgets" @@ -2397,6 +2639,7 @@ _deck: mentions: "Erwähnungen" direct: "Direktnachrichten" roleTimeline: "Rollenchronik" + chat: "Chat" _dialog: charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" @@ -2408,6 +2651,7 @@ _drivecleaner: orderByCreatedAtAsc: "Aufsteigendes Erstelldatum" _webhookSettings: createWebhook: "Webhook erstellen" + modifyWebhook: "Webhook bearbeiten" name: "Name" secret: "Secret" trigger: "Auslöser" @@ -2420,12 +2664,29 @@ _webhookSettings: renote: "Wenn du ein Renote erhältst" reaction: "Wenn du eine Reaktion erhältst" mention: "Wenn du erwähnt wirst" + _systemEvents: + abuseReport: "Wenn eine neue Meldung eingeht" + abuseReportResolved: "Wenn eine Meldung gelöst wird" + userCreated: "Beim Anlegen eines Benutzers" + inactiveModeratorsWarning: "Wenn Moderatoren für eine gewisse Zeit inaktiv sind" + inactiveModeratorsInvitationOnlyChanged: "Wenn ein Moderator über einen gewissen Zeitraum inaktiv war und der Server auf Einladungsbasis umgestellt wird" + deleteConfirm: "Bist du sicher, dass du den Webhook löschen willst?" + testRemarks: "Klicke auf die Schaltfläche rechts neben dem Schalter, um einen Test-Webhook mit Dummy-Daten zu senden." _abuseReport: _notificationRecipient: createRecipient: "Meldungsempfänger hinzufügen" + modifyRecipient: "Bearbeite einen Empfänger für Meldungen" + recipientType: "Art der Benachrichtigung" _recipientType: mail: "Email" + webhook: "Webhook" + _captions: + mail: "Die Benachrichtigung wird bei Eingang einer Meldung an die E-Mail-Adressen der Moderatoren gesendet" + webhook: "Sendet eine Benachrichtigung an den System Webhook, wenn eine Meldung eingegangen ist oder gelöst wurde" keywords: "Schlüsselwort" + notifiedUser: "Zu benachrichtigender Benutzer" + notifiedWebhook: "Zu verwendender Webhook" + deleteConfirm: "Bist du sicher, dass du den Empfänger der Benachrichtigung entfernen möchtest?" _moderationLogTypes: createRole: "Rolle erstellt" deleteRole: "Rolle gelöscht" @@ -2450,9 +2711,12 @@ _moderationLogTypes: resetPassword: "Passwort zurückgesetzt" suspendRemoteInstance: "Fremde Instanz gesperrt" unsuspendRemoteInstance: "Fremde Instanz entsperrt" + updateRemoteInstanceNote: "Aktualisierung der Moderationshinweise für fremde Server." markSensitiveDriveFile: "Datei als sensitiv markiert" unmarkSensitiveDriveFile: "Datei als nicht sensitiv markiert" resolveAbuseReport: "Meldung bearbeitet" + forwardAbuseReport: "Meldung weitergeleitet" + updateAbuseReportNote: "Moderationsnotiz einer Meldung aktualisiert" createInvitation: "Einladung erstellt" createAd: "Werbung erstellt" deleteAd: "Werbung gelöscht" @@ -2465,8 +2729,15 @@ _moderationLogTypes: createSystemWebhook: "System-Webhook erstellt" updateSystemWebhook: "System-Webhook aktualisiert" deleteSystemWebhook: "System-Webhook gelöscht" + createAbuseReportNotificationRecipient: "Empfänger für Meldungen erstellt" + updateAbuseReportNotificationRecipient: "Empfänger für Meldungen aktualisiert" + deleteAbuseReportNotificationRecipient: "Empfänger für Meldungen entfernt" + deleteAccount: "Benutzerkonto gelöscht" deletePage: "Seite gelöscht" + deleteFlash: "Play gelöscht" deleteGalleryPost: "Galeriebeitrag gelöscht" + deleteChatRoom: "Chatraum gelöscht" + updateProxyAccountDescription: "Beschreibung des Proxy-Benutzerkontos aktualisiert" _fileViewer: title: "Dateiinformationen" type: "Dateityp" @@ -2480,10 +2751,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Überprüfe vor Installation die Vertrauenswürdigkeit des Vertreibers." _plugin: title: "Möchtest du dieses Plugin installieren?" - metaTitle: "Plugininformation" _theme: title: "Möchten du dieses Farbschema installieren?" - metaTitle: "Farbschemainfo" _meta: base: "Farbschemavorlage" _vendorInfo: @@ -2516,16 +2785,67 @@ _externalResourceInstaller: _themeInstallFailed: title: "Das Farbschema konnte nicht installiert werden" description: "Während der Installation des Farbschemas ist ein Problem aufgetreten. Bitte versuche es erneut. Detaillierte Fehlerinformationen können über die Javascript-Konsole abgerufen werden." +_dataSaver: + _media: + title: "Laden von Medien verhindern" + description: "Verhindert, dass Bilder/Videos automatisch geladen werden. Ausgeblendete Bilder/Videos werden geladen, wenn du auf sie tippst." + _avatar: + title: "Animierte Profilbilder deaktivieren" + description: "Die Animation von Profilbildern wird angehalten. Da animierte Bilder eine größere Dateigröße haben können als normale Bilder, kann dies den Datenverkehr weiter reduzieren." + _urlPreview: + title: "URL-Vorschaubilder ausblenden" + description: "URL-Vorschaubilder werden nicht mehr geladen." + _code: + title: "Code-Hervorhebungen ausblenden" + description: "Wenn Code-Hervorhebungen in MFM usw. verwendet werden, werden sie erst geladen, wenn sie angetippt werden. Die Syntaxhervorhebung erfordert das Herunterladen der Definitionsdateien für jede Programmiersprache. Es ist daher zu erwarten, dass die Deaktivierung des automatischen Ladens dieser Dateien die Menge des Datenverkehrs reduziert." _hemisphere: N: "Nördliche Erdhalbkugel" S: "Südliche Erdhalbkugel" caption: "Wird in einigen Client-Einstellungen zur Bestimmung der Jahreszeit verwendet." _reversi: + reversi: "Reversi" + gameSettings: "Spieleinstellungen" + chooseBoard: "Spielbrett auswählen" blackOrWhite: "Schwarz/Weiß" + blackIs: "{name} spielt Schwarz" rules: "Regeln" + thisGameIsStartedSoon: "Das Spiel wird in Kürze beginnen" + waitingForOther: "Warte auf den Zug des Gegenspielers" + waitingForMe: "Warte auf deinen Zug" + waitingBoth: "Mach dich bereit" + ready: "Bereit" + cancelReady: "Nicht bereit" + opponentTurn: "Dein Gegner ist an der Reihe" + myTurn: "Du bist am Zug" + turnOf: "{name} ist am Zug" + pastTurnOf: "Zug von {name}" + surrender: "Aufgeben" + surrendered: "Aufgegeben" + timeout: "Zeit abgelaufen" + drawn: "Unentschieden" + won: "{name} hat gewonnen" black: "Schwarz" white: "Weiß" total: "Gesamt" + turnCount: " Zug {count}" + myGames: "Meine Runden" + allGames: "Alle Runden" + ended: "Beendet" + playing: "Partie läuft" + isLlotheo: "Der mit weniger Steinen gewinnt (Llotheo)" + loopedMap: "Wiederholendes Spielbrett" + canPutEverywhere: "Steine können überall platziert werden" + timeLimitForEachTurn: "Zeitlimit eines Zugs" + freeMatch: "Freies Spiel" + lookingForPlayer: "Gegner werden gesucht..." + gameCanceled: "Das Spiel wurde abgesagt." + shareToTlTheGameWhenStart: "Spiel in der Chronik teilen, wenn es gestartet wurde" + iStartedAGame: "Das Spiel hat begonnen! #MisskeyReversi" + opponentHasSettingsChanged: "Der Gegner hat seine Einstellungen geändert." + allowIrregularRules: "Irreguläre Regeln (völlig frei)" + disallowIrregularRules: "Keine irregulären Regeln" + showBoardLabels: "Anzeige der Zeilen- und Spaltennummern am Spielbrett" + useAvatarAsStone: "Steine in Benutzeravatare umwandeln" _offlineScreen: title: "Offline - keine Verbindung zum Server möglich" header: "Verbindung zum Server nicht möglich" @@ -2539,14 +2859,79 @@ _urlPreviewSetting: requireContentLength: "Vorschau nur generieren, wenn Content-Length verfügbar ist" requireContentLengthDescription: "Wenn der Server keine Content-Length zurückgibt, wird keine Vorschau erzeugt." userAgent: "User-Agent" + userAgentDescription: "Legt den User-Agent fest, der beim Abrufen der Vorschau verwendet werden soll. Bleibt er leer, wird der Standard-User-Agent verwendet." + summaryProxy: "Proxy-Endpunkte, die Vorschaubilder erzeugen" + summaryProxyDescription: "Generierung von Vorschaubildern mit Summaly Proxy anstelle von Misskey selbst." + summaryProxyDescription2: "Die folgenden Parameter werden als Abfrage-Strings mit dem Proxy verknüpft. Wenn der Proxy sie nicht unterstützt, werden die Werte ignoriert." _mediaControls: + pip: "Bild-in-Bild" playbackRate: "Wiedergabegeschwindigkeit" + loop: "Endloswiedergabe" _contextMenu: title: "Kontextmenü" app: "Anwendung" + appWithShift: "Anwendung per Umschalttaste" + native: "Natives Browsermenü" _gridComponent: _error: requiredValue: "Dieser Wert ist ein Pflichtfeld" + columnTypeNotSupport: "Die Validierung regulärer Ausdrücke wird nur für Spalten vom Typ \"Text\" unterstützt." + patternNotMatch: "Dieser Wert stimmt nicht mit dem Schema in {pattern} überein" + notUnique: "Dieser Wert muss eindeutig sein" +_roleSelectDialog: + notSelected: "Nicht ausgewählt" +_customEmojisManager: + _gridCommon: + copySelectionRows: "Ausgewählte Zeilen kopieren" + copySelectionRanges: "Auswahl kopieren" + deleteSelectionRows: "Ausgewählte Zeilen löschen" + deleteSelectionRanges: "Zeilen in der Auswahl löschen" + searchSettings: "Sucheinstellungen" + searchSettingCaption: "Detaillierte Suchkriterien festlegen." + searchLimit: "Anzahl der Ergebnisse" + sortOrder: "Sortierung" + registrationLogs: "Registrierungsprotokoll" + registrationLogsCaption: "Protokolle werden beim Aktualisieren oder Löschen von Emojis angezeigt. Sie verschwinden nach dem Aktualisieren oder Löschen, dem Wechsel zu einer neuen Seite oder dem Neuladen." + alertEmojisRegisterFailedDescription: "Emoji konnte nicht aktualisiert oder gelöscht werden. Bitte prüfe das Registrierungsprotokoll für Details." + _logs: + showSuccessLogSwitch: "Erfolgsprotokoll zeigen" + failureLogNothing: "Es gibt kein Fehlerprotokoll." + logNothing: "Keine Protokoll-Einträge." + _remote: + selectionRowDetail: "Details der ausgewählten Zeile" + importSelectionRows: "Ausgewählte Zeilen importieren" + importSelectionRangesRows: "Zeilen in der Auswahl importieren" + importEmojisButton: "Ausgewählte Emojis importieren" + confirmImportEmojisTitle: "Emojis importieren" + confirmImportEmojisDescription: "Importiere {count} Emoji(s), die von entfernten Server empfangen wurden. Bitte achte genau auf die Lizenz der Emojis. Bist du sicher, dass du fortfahren möchtest?" + _local: + tabTitleList: "Hinzugefügte Emojis" + tabTitleRegister: "Emojis hinzufügen" + _list: + emojisNothing: "Es wurden keine Emojis hinzugefügt." + markAsDeleteTargetRows: "Ausgewählte Zeilen als zu löschendes Element markieren" + markAsDeleteTargetRanges: "Zeilen in der Auswahl als zu löschendes Element markieren" + alertUpdateEmojisNothingDescription: "Es wurden keine Emojis geändert." + alertDeleteEmojisNothingDescription: "Es gibt keine zu löschenden Emojis." + confirmMovePage: "Möchten Sie die Seiten verschieben?" + confirmChangeView: "Möchten Sie die Darstellung wechseln?" + confirmUpdateEmojisDescription: "Aktualisiere {count} Emoji(s). Willst du fortfahren?" + confirmDeleteEmojisDescription: "Lösche {count} ausgewählte Emoji(s). Willst du fortfahren?" + confirmResetDescription: "Alle bisher vorgenommenen Änderungen werden zurückgesetzt." + confirmMovePageDesciption: "An den Emojis auf dieser Seite wurden Änderungen vorgenommen.\nWenn du die Seite verlässt, ohne zu speichern, werden alle auf dieser Seite vorgenommenen Änderungen verworfen." + dialogSelectRoleTitle: "Suche nach dem Rollensatz in Emojis" + _register: + uploadSettingTitle: "Upload-Einstellungen" + uploadSettingDescription: "Hier kannst du das Verhalten beim Hochladen von Emojis konfigurieren." + directoryToCategoryLabel: "Gib den Namen des Verzeichnisses in das Feld „Kategorie“ ein" + directoryToCategoryCaption: "Wenn du ein Verzeichnis ziehst und ablegst, gib den Verzeichnisnamen in das Feld „Kategorie“ ein." + emojiInputAreaCaption: "Wählen Sie die Emojis aus, die Sie mit einer der folgenden Methoden speichern möchten." + emojiInputAreaList1: "Ziehe Bilddateien oder Verzeichnisse per Drag-and-drop in diesen Rahmen" + emojiInputAreaList2: "Klicke auf diesen Link, um von deinem PC aus zu wählen" + emojiInputAreaList3: "Klicke auf diesen Link, um vom Drive aus zu wählen" + confirmRegisterEmojisDescription: "Füge die in der Liste aufgeführten Emojis als neue benutzerdefinierte Emojis hinzu. Bist du sicher? (Um eine Überlastung zu vermeiden, können nur {count} Emoji(s) in einem Vorgang hinzugefügt werden)" + confirmClearEmojisDescription: "Verwerfe die Bearbeitungen und lösche die Emojis aus der Liste. Bist du sicher, dass du fortfahren möchtest?" + confirmUploadEmojisDescription: "Lade die {count} abgelegte(n) Datei(en) in das Drive hoch. Bist du sicher, dass du fortfahren möchtest?" _embedCodeGen: title: "Einbettungscode anpassen" header: "Kopfzeile anzeigen" @@ -2554,6 +2939,9 @@ _embedCodeGen: maxHeight: "Maximale Höhe" maxHeightDescription: "Der Wert 0 deaktiviert die Einstellung der maximalen Höhe. Gib einen Wert an, um zu verhindern, dass das Widget weiterhin vertikal vergrößert wird." maxHeightWarn: "Die Begrenzung der maximalen Höhe ist deaktiviert (0). Wenn dies nicht beabsichtigt war, setze die maximale Höhe auf einen Wert fest." + previewIsNotActual: "Die Anzeige weicht von der tatsächlichen Einbettung ab, da sie den auf dem Vorschaufenster angezeigten Bereich überschreitet." + rounded: "Ecken abrunden" + border: "Dem äußeren Rand einen Rahmen hinzufügen" applyToPreview: "Auf die Vorschau anwenden" generateCode: "Einbettungscode generieren" codeGenerated: "Der Code wurde generiert" @@ -2562,7 +2950,11 @@ _selfXssPrevention: warning: "WARNUNG" title: "„Füge in diesen Bereich etwas ein“ ist eine Betrugsmasche." description1: "Wenn du hier etwas einfügst, könnte ein böswilliger Benutzer dein Konto übernehmen oder deine persönlichen Daten stehlen." + description2: "Wenn du das nicht genau verstehst, was du einfügst, %csolltest du die Eingabe abbrechen und das Fenster schließen." description3: "Weitere Informationen findest du hier. {link}" +_followRequest: + recieved: "Anfrage erhalten" + sent: "Anfrage gesendet" _remoteLookupErrors: _federationNotAllowed: title: "Kommunikation mit diesem Server nicht möglich" @@ -2570,6 +2962,45 @@ _remoteLookupErrors: _uriInvalid: title: "URI ist fehlerhaft" description: "Es gibt ein Problem mit der von dir eingegebenen URI. Bitte prüfe, ob du Zeichen eingegeben hast, die in der URI nicht verwendet werden können." + _requestFailed: + title: "Anfrage fehlgeschlagen" + description: "Die Kommunikation mit diesem Server ist fehlgeschlagen. Der Server ist möglicherweise nicht erreichbar. Bitte vergewissere dich auch, dass du keine ungültige oder nicht existierende URI eingegeben hast." + _responseInvalid: + title: "Die Antwort ist ungültig" + description: "Die Kommunikation mit dem Server war erfolgreich, aber die erhaltenen Daten waren nicht korrekt. Wenn du Remote-Inhalte über einen Server eines Dritten abfragst, verwende bitte erneut eine URI, die vom Ursprungsserver abgerufen werden kann." _noSuchObject: title: "Nicht gefunden" description: "Die angeforderte Ressource konnte nicht gefunden werden, bitte überprüfe die URI erneut." +_captcha: + verify: "Bitte beantworte das CAPTCHA" + testSiteKeyMessage: "Du kannst die Vorschau prüfen, indem du die Testwerte für den Site- und Secret-Key eingibst. Weitere Informationen findest du auf der folgenden Seite." + _error: + _requestFailed: + title: "CAPTCHA-Anfrage fehlgeschlagen." + text: "Bitte probiere es später noch einmal oder überprüfe die Einstellungen erneut." + _verificationFailed: + title: "CAPTCHA-Prüfung fehlgeschlagen" + text: "Bitte überprüfe nochmals, ob die Einstellungen korrekt sind." + _unknown: + title: "CAPTCHA-Fehler" + text: "Es ist ein unerwarteter Fehler aufgetreten." +_bootErrors: + title: "Laden fehlgeschlagen" + serverError: "Wenn das Problem nach kurzem Warten und erneutem Laden immer noch nicht behoben ist, wende dich bitte an den Serveradministrator und gib die folgende Fehler-ID an." + solution: "Folgendes könnte das Problem lösen." + solution1: "Aktualisiere deinen Browser und dein Betriebssystem auf die neueste Version" + solution2: "Deaktiviere den Werbeblocker" + solution3: "Leere den Browser-Cache" + solution4: "(Tor Browser) Setze dom.webaudio.enabled auf true" + otherOption: "Weitere Optionen" + otherOption1: "Client-Einstellungen und Cache löschen" + otherOption2: "Einfachen Client starten" + otherOption3: "Starte das Reparaturwerkzeug" +_search: + searchScopeAll: "Alle" + searchScopeLocal: "Lokal" + searchScopeServer: "Bestimmter Server" + searchScopeUser: "Spezifischer Benutzer" + pleaseEnterServerHost: "Gib den Server-Host ein" + pleaseSelectUser: "Benutzer auswählen" + serverHostPlaceholder: "Beispiel: misskey.example.com" diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 4657842ca5..c8aff304d2 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -162,14 +162,12 @@ imageUrl: "URL εικόνας" remove: "Διαγραφή" removed: "Η διαγραφή ολοκληρώθηκε επιτυχώς" saved: "Αποθηκεύτηκε" -messaging: "Συνομιλία" upload: "Ανεβάστε" fromDrive: "Από τον Αποθηκευτικό Χώρο" fromUrl: "Από URL" uploadFromUrl: "Ανεβάστε από URL" explore: "Εξερευνήστε" messageRead: "Διαβάστηκε" -startMessaging: "Ξεκινήστε μία συνομιλία" nUsersRead: "διαβάστηκε από {n}" start: "Ας αρχίσουμε" home: "Κεντρικό" @@ -288,6 +286,11 @@ cannotUploadBecauseNoFreeSpace: "Το ανέβασμα απέτυχε λόγω icon: "Εικονίδιο" replies: "Απάντηση" renotes: "Κοινοποίηση σημειώματος" +postForm: "Φόρμα δημοσίευσης" +information: "Πληροφορίες" +_chat: + members: "Μέλη" + home: "Κεντρικό" _email: _follow: title: "Έχετε ένα νέο ακόλουθο" @@ -321,6 +324,7 @@ _permissions: "write:notifications": "Διαχειριστείτε τις ειδοποιήσεις σας" "read:pages": "Δείτε τις Σελίδες σας" "write:pages": "Επεξεργαστείτε ή διαγράψτε τις σελίδες σας" + "write:chat": "Γράψτε ή διαγράψτε μηνύματα συνομιλίας" _antennaSources: all: "Όλα τα σημειώματα" homeTimeline: "Σημειώματα από μέλη που ακολουθείτε" @@ -397,3 +401,5 @@ _moderationLogTypes: suspend: "Αποβολή" _reversi: total: "Σύνολο" +_search: + searchScopeLocal: "Τοπικό" diff --git a/locales/en-US.yml b/locales/en-US.yml index 7a03669101..230717f372 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -132,7 +132,7 @@ reaction: "Reactions" reactions: "Reactions" emojiPicker: "Emoji picker" pinnedEmojisForReactionSettingDescription: "Set the emojis to be pinned and displayed when reacting." -pinnedEmojisSettingDescription: "Set the emojis to be pinned and displayed when viewing emoji picker." +pinnedEmojisSettingDescription: "Set the emojis to be pinned and displayed when viewing emoji picker" emojiPickerDisplay: "Emoji picker display" overwriteFromPinnedEmojisForReaction: "Override from reaction settings" overwriteFromPinnedEmojis: "Override from general settings" @@ -289,7 +289,6 @@ deleteAreYouSure: "Are you sure that you want to delete \"{x}\"?" resetAreYouSure: "Really reset?" areYouSure: "Are you sure?" saved: "Saved" -messaging: "Chat" upload: "Upload" keepOriginalUploading: "Keep original image" keepOriginalUploadingDescription: "Saves the originally uploaded image as-is. If turned off, a version to display on the web will be generated on upload." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "It may take some time until the upload is complete." explore: "Explore" messageRead: "Read" noMoreHistory: "There is no further history" -startMessaging: "Start a new chat" +startChat: "Start chat" nUsersRead: "read by {n}" agreeTo: "I agree to {0}" agree: "Agree" @@ -346,7 +345,7 @@ emptyDrive: "Your Drive is empty" emptyFolder: "This folder is empty" unableToDelete: "Unable to delete" inputNewFileName: "Enter a new filename" -inputNewDescription: "Enter new caption" +inputNewDescription: "Enter new alt text" inputNewFolderName: "Enter a new folder name" circularReferenceFolder: "The destination folder is a subfolder of the folder you wish to move." hasChildFilesOrFolders: "Since this folder is not empty, it can not be deleted." @@ -425,6 +424,7 @@ antennaExcludeBots: "Exclude bot accounts" antennaKeywordsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition." notifyAntenna: "Notify about new notes" withFileAntenna: "Only notes with files" +excludeNotesInSensitiveChannel: "Exclude notes from sensitive channels" enableServiceworker: "Enable Push-Notifications for your Browser" antennaUsersDescription: "List one username per line" caseSensitive: "Case sensitive" @@ -491,8 +491,6 @@ noteOf: "Note by {user}" quoteAttached: "Quote" quoteQuestion: "Append as quote?" attachAsFileQuestion: "The text in clipboard is long. Would you want to attach it as text file?" -noMessagesYet: "No messages yet" -newMessageExists: "There are new messages" onlyOneFileCanBeAttached: "You can only attach one file to a message" signinRequired: "Please register or sign in before continuing" signinOrContinueOnRemote: "To continue, you need to move your server or sign up / log in to this server." @@ -586,7 +584,7 @@ popout: "Pop-out" volume: "Volume" masterVolume: "Master volume" notUseSound: "Disable sound" -useSoundOnlyWhenActive: "Output sounds only if Misskey is active." +useSoundOnlyWhenActive: "Output sounds only if Misskey is active" details: "Details" renoteDetails: "Renote details" chooseEmoji: "Select an emoji" @@ -646,8 +644,8 @@ disablePlayer: "Close video player" expandTweet: "Expand post" themeEditor: "Theme editor" description: "Description" -describeFile: "Add caption" -enterFileDescription: "Enter caption" +describeFile: "Add alt text" +enterFileDescription: "Enter alt text" author: "Author" leaveConfirm: "There are unsaved changes. Do you want to discard them?" manage: "Management" @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name} said something about \"{word}\"" makeActive: "Activate" display: "Display" copy: "Copy" +copiedToClipboard: "Copied to clipboard" metrics: "Metrics" overview: "Overview" logs: "Logs" @@ -979,7 +978,8 @@ deleteAccount: "Delete account" document: "Documentation" numberOfPageCache: "Number of cached pages" numberOfPageCacheDescription: "Increasing this number will improve convenience for but cause more load as more memory usage on the user's device." -logoutConfirm: "Really log out?" +logoutConfirm: "Are you sure you want to log out?" +logoutWillClearClientData: "Logging out will erase the settings of the client from the browser. In order to be able to restore the settings upon logging in again, you must enable automatic backup of your settings." lastActiveDate: "Last used at" statusbar: "Status bar" pleaseSelect: "Select an option" @@ -1016,7 +1016,7 @@ sendPushNotificationReadMessageCaption: "This may increase the power consumption windowMaximize: "Maximize" windowMinimize: "Minimize" windowRestore: "Restore" -caption: "Caption" +caption: "Alt text" loggedInAsBot: "Currently logged in as bot" tools: "Tools" cannotLoad: "Unable to load" @@ -1261,7 +1261,7 @@ copyReplayData: "Copy replay data" ranking: "Ranking" lastNDays: "Last {n} days" backToTitle: "Go back to title" -hemisphere: "Where are you located" +hemisphere: "Where you live" withSensitive: "Include notes with sensitive files" userSaysSomethingSensitive: "Post by {name} contains sensitive content" enableHorizontalSwipe: "Swipe to switch tabs" @@ -1272,7 +1272,7 @@ notUsePleaseLeaveBlank: "Leave blank if not used" useTotp: "Enter the One-Time Password" useBackupCode: "Use the backup codes" launchApp: "Launch the app" -useNativeUIForVideoAudioPlayer: "Use UI of browser when play video and audio" +useNativeUIForVideoAudioPlayer: "Use UI of browser when play video and audio\n" keepOriginalFilename: "Keep original file name" keepOriginalFilenameDescription: "If you turn off this setting, files names will be replaced with random string automatically when you upload files." noDescription: "There is no explanation" @@ -1309,6 +1309,136 @@ availableRoles: "Available roles" acknowledgeNotesAndEnable: "Turn on after understanding the precautions." federationSpecified: "This server is operated in an allowlist federation. Interacting with servers other than those designated by the administrator is not allowed." federationDisabled: "Federation is disabled on this server. You cannot interact with users on other servers." +confirmOnReact: "Confirm when reacting" +reactAreYouSure: "Would you like to add a \"{emoji}\" reaction?" +markAsSensitiveConfirm: "Do you want to set this media as sensitive?" +unmarkAsSensitiveConfirm: "Do you want to remove the sensitive designation for this media?" +preferences: "Preferences" +accessibility: "Accessibility" +preferencesProfile: "Preferences profile" +copyPreferenceId: "Copy the preference ID" +resetToDefaultValue: "Revert to default" +overrideByAccount: "Override by the account" +untitled: "Untitled" +noName: "No name" +skip: "Skip" +restore: "Restore" +syncBetweenDevices: "Sync between devices" +preferenceSyncConflictTitle: "The configured value exists on the server." +preferenceSyncConflictText: "The sync enabled settings will save their values to the server. However, there are existing values on the server. Which set of values would you like to overwrite?" +preferenceSyncConflictChoiceServer: "Configured value on server" +preferenceSyncConflictChoiceDevice: "Configured value on device" +preferenceSyncConflictChoiceCancel: "Cancel enabling sync" +paste: "Paste" +emojiPalette: "Emoji palette" +postForm: "Posting form" +textCount: "Character count" +information: "About" +chat: "Chat" +migrateOldSettings: "Migrate old client settings" +migrateOldSettings_description: "This should be done automatically but if for some reason the migration was not successful, you can trigger the migration process yourself manually. The current configuration information will be overwritten." +compress: "Compress" +right: "Right" +bottom: "Bottom" +top: "Top" +embed: "Embed" +settingsMigrating: "Settings are being migrated, please wait a moment... (You can also migrate manually later by going to Settings→Others→Migrate old settings)" +readonly: "Read only" +goToDeck: "Return to Deck" +federationJobs: "Federation Jobs" +driveAboutTip: "In Drive, a list of files you've uploaded in the past will be displayed.
\nYou can reuse these files when attaching them to notes, or you can upload files in advance to post later.
\nBe careful when deleting a file, as it will not be available in all places where it was used (such as notes, pages, avatars, banners, etc.).
\nYou can also create folders to organize your files." +_chat: + noMessagesYet: "No messages yet" + newMessage: "New message" + individualChat: "Private Chat" + individualChat_description: "Have a private chat with another person." + roomChat: "Room Chat" + roomChat_description: "A chat room which can have multiple people.\nYou can also invite people who don't allow private chats if they accept the invite." + createRoom: "Create Room" + inviteUserToChat: "Invite users to start chatting" + yourRooms: "Created rooms" + joiningRooms: "Joined rooms" + invitations: "Invite" + noInvitations: "No invitations" + history: "History" + noHistory: "No history available" + noRooms: "No rooms found" + inviteUser: "Invite Users" + sentInvitations: "Sent Invites" + join: "Join" + ignore: "Ignore" + leave: "Leave room" + members: "Members" + searchMessages: "Search messages" + home: "Home" + send: "Send" + newline: "New line" + muteThisRoom: "Mute room" + deleteRoom: "Delete room" + chatNotAvailableForThisAccountOrServer: "Chat is not enabled on this server or for this account." + chatIsReadOnlyForThisAccountOrServer: "Chat is read-only on this instance or this account. You cannot write new messages or create/join chat rooms." + chatNotAvailableInOtherAccount: "The chat function is disabled for the other user." + cannotChatWithTheUser: "Cannot start a chat with this user" + cannotChatWithTheUser_description: "Chat is either unavailable or the other party has not enabled chat." + chatWithThisUser: "Chat with user" + thisUserAllowsChatOnlyFromFollowers: "This user accepts chats from followers only." + thisUserAllowsChatOnlyFromFollowing: "This user accepts chats only from users they follow." + thisUserAllowsChatOnlyFromMutualFollowing: "This user only accepts chats from users who are mutual followers." + thisUserNotAllowedChatAnyone: "This user is not accepting chats from anyone." + chatAllowedUsers: "Who to allow chatting with" + chatAllowedUsers_note: "You can chat with anyone to whom you have sent a chat message regardless of this setting." + _chatAllowedUsers: + everyone: "Everyone" + followers: "Only your followers" + following: "Only users you are following" + mutual: "Mutual followers only" + none: "Nobody" +_emojiPalette: + palettes: "Palette" + enableSyncBetweenDevicesForPalettes: "Enable palette sync between devices" + paletteForMain: "Main palette" + paletteForReaction: "Reaction palette" +_settings: + driveBanner: "You can manage and configure the drive, check usage, and configure file upload settings." + pluginBanner: "You can extend client features with plugins. You can install plugins, configure and manage individually." + notificationsBanner: "You can configure the types and range of notifications from the server and push notifications." + api: "API" + webhook: "Webhook" + serviceConnection: "Service integration" + serviceConnectionBanner: "Manage and configure access tokens and Webhooks to integrate with external apps or services." + accountData: "Account data" + accountDataBanner: "Export and import to manage account data." + muteAndBlockBanner: "You can configure and manage settings to hide content and restrict actions from specific users." + accessibilityBanner: "You can personalize the client's visuals and behavior, and configure settings to optimize usage." + privacyBanner: "You can configure settings related to account privacy, such as content visibility, discoverability, and follow approval." + securityBanner: "You can configure settings related to account security, such as password, login methods, authentication apps, and Passkeys." + preferencesBanner: "You can configure the overall behavior of the client according to your preferences." + appearanceBanner: "You can configure the appearance and display settings for the client according to your preferences." + soundsBanner: "You can configure the sound settings for playback in the client." + timelineAndNote: "Timeline and note" + makeEveryTextElementsSelectable: "Make all text elements selectable" + makeEveryTextElementsSelectable_description: "Enabling this may reduce usability in some situations." + useStickyIcons: "Make icons follow while scrolling" + showNavbarSubButtons: "Show sub-buttons on the navigation bar" + ifOn: "When turned on" + ifOff: "When turned off" + enableSyncThemesBetweenDevices: "Synchronize installed themes across devices" + _chat: + showSenderName: "Show sender's name" + sendOnEnter: "Press Enter to send" +_preferencesProfile: + profileName: "Profile name" + profileNameDescription: "Set a name that identifies this device." + profileNameDescription2: "Example: \"Main PC\", \"Smartphone\"" +_preferencesBackup: + autoBackup: "Auto backup" + restoreFromBackup: "Restore from backup" + noBackupsFoundTitle: "No backups found" + noBackupsFoundDescription: "No auto-created backups were found, but if you have manually saved a backup file, you can import and restore it." + selectBackupToRestore: "Select a backup to restore" + youNeedToNameYourProfileToEnableAutoBackup: "A profile name must be set to enable auto backup." + autoPreferencesBackupIsNotEnabledForThisDevice: "Settings auto backup is not enabled on this device." + backupFound: "Settings backup is found" _accountSettings: requireSigninToViewContents: "Require sign-in to view contents" requireSigninToViewContentsDescription1: "Require login to view all notes and other content you have created. This will have the effect of preventing crawlers from collecting your information." @@ -1319,6 +1449,7 @@ _accountSettings: makeNotesHiddenBefore: "Make past notes private" makeNotesHiddenBeforeDescription: "While this feature is enabled, notes that are past the set date and time or have been visible only to you. When it is deactivated, the note publication status will also be restored." mayNotEffectForFederatedNotes: "Notes federated to a remote server may not be affected." + mayNotEffectSomeSituations: "These restrictions are simplified. They may not apply in some situations, such as when viewing on a remote server or during moderation." notesHavePassedSpecifiedPeriod: "Note that the specified time has passed" notesOlderThanSpecifiedDateAndTime: "Notes before the specified date and time" _abuseUserReport: @@ -1327,7 +1458,7 @@ _abuseUserReport: resolve: "Resolve" accept: "Accept" reject: "Reject" - resolveTutorial: "If the report is legitimate in content, select \"Accept\" to mark the case as resolved in the affirmative.\nIf the content of the report is not legitimate, select \"Reject\" to mark the case as resolved in the negative." + resolveTutorial: "If the report's content is legitimate, select \"Accept\" to mark it as resolved.\nIf the report's content is illegitimate, select \"Reject\" to ignore it." _delivery: status: "Delivery status" stop: "Suspended" @@ -1764,6 +1895,8 @@ _role: descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled." displayOrder: "Position" descriptionOfDisplayOrder: "The higher the number, the higher its UI position." + preserveAssignmentOnMoveAccount: "Preserve role assignment during migration" + preserveAssignmentOnMoveAccount_description: "When turned on, this role will be carried over to the destination account when an account with this role is migrated." canEditMembersByModerator: "Allow moderators to edit the list of members for this role" descriptionOfCanEditMembersByModerator: "When turned on, moderators as well as administrators will be able to assign and unassign users to this role. When turned off, only administrators will be able to assign users." priority: "Priority" @@ -1783,6 +1916,7 @@ _role: canManageCustomEmojis: "Can manage custom emojis" canManageAvatarDecorations: "Manage avatar decorations" driveCapacity: "Drive capacity" + maxFileSize: "Upload-able max file size" alwaysMarkNsfw: "Always mark files as NSFW" canUpdateBioMedia: "Can edit an icon or a banner image" pinMax: "Maximum number of pinned notes" @@ -1804,6 +1938,7 @@ _role: canImportFollowing: "Allow importing following" canImportMuting: "Allow importing muting" canImportUserLists: "Allow importing lists" + chatAvailability: "Allow Chat" _condition: roleAssignedTo: "Assigned to manual roles" isLocal: "Local user" @@ -1967,6 +2102,7 @@ _theme: installed: "{name} has been installed" installedThemes: "Installed themes" builtinThemes: "Built-in themes" + instanceTheme: "Server theme" alreadyInstalled: "This theme is already installed" invalid: "The format of this theme is invalid" make: "Make a theme" @@ -1999,7 +2135,6 @@ _theme: header: "Header" navBg: "Sidebar background" navFg: "Sidebar text" - navHoverFg: "Sidebar text (Hover)" navActive: "Sidebar text (Active)" navIndicator: "Sidebar indicator" link: "Link" @@ -2022,17 +2157,15 @@ _theme: buttonHoverBg: "Button background (Hover)" inputBorder: "Input field border" driveFolderBg: "Drive folder background" - wallpaperOverlay: "Wallpaper overlay" badge: "Badge" messageBg: "Chat background" - accentDarken: "Accent (Darkened)" - accentLighten: "Accent (Lightened)" fgHighlighted: "Highlighted Text" _sfx: note: "New note" noteMy: "Own note" notification: "Notifications" reaction: "On choosing a reaction" + chatMessage: "Chat Messages" _soundSettings: driveFile: "Use an audio file in Drive." driveFileWarn: "Select an audio file from Drive." @@ -2179,6 +2312,8 @@ _permissions: "read:clip-favorite": "View favorited clips" "read:federation": "Get federation data" "write:report-abuse": "Report violation" + "write:chat": "Compose or delete chat messages" + "read:chat": "Browse Chat" _auth: shareAccessTitle: "Granting application permissions" shareAccess: "Would you like to authorize \"{name}\" to access this account?" @@ -2237,6 +2372,7 @@ _widgets: chooseList: "Select a list" clicker: "Clicker" birthdayFollowings: "Today's Birthdays" + chat: "Chat" _cw: hide: "Hide" show: "Show content" @@ -2427,6 +2563,7 @@ _notification: newNote: "New note" unreadAntennaNote: "Antenna {name}" roleAssigned: "Role given" + chatRoomInvitationReceived: "You have been invited to a chat room" emptyPushNotificationMessage: "Push notifications have been updated" achievementEarned: "Achievement unlocked" testNotification: "Test notification" @@ -2440,6 +2577,8 @@ _notification: flushNotification: "Clear notifications" exportOfXCompleted: "Export of {x} has been completed" login: "Someone logged in" + createToken: "An access token has been created" + createTokenDescription: "If you have no idea, delete the access token through \"{text}\"." _types: all: "All" note: "New notes" @@ -2453,9 +2592,11 @@ _notification: receiveFollowRequest: "Received follow requests" followRequestAccepted: "Accepted follow requests" roleAssigned: "Role given" + chatRoomInvitationReceived: "Invited to chat room" achievementEarned: "Achievement unlocked" exportCompleted: "The export has been completed" login: "Sign In" + createToken: "Create access token" test: "Notification test" app: "Notifications from linked apps" _actions: @@ -2465,6 +2606,9 @@ _notification: _deck: alwaysShowMainColumn: "Always show main column" columnAlign: "Align columns" + columnGap: "Margin between columns" + deckMenuPosition: "Deck menu position" + navbarPosition: "Navigation bar position" addColumn: "Add column" newNoteNotificationSettings: "Notification setting for new notes" configureColumn: "Column settings" @@ -2478,11 +2622,12 @@ _deck: newProfile: "New profile" deleteProfile: "Delete profile" introduction: "Create the perfect interface for you by arranging columns freely!" - introduction2: "Click on the + on the right of the screen to add new colums whenever you want." + introduction2: "Click on the + on the right of the screen to add new columns whenever you want." widgetsIntroduction: "Please select \"Edit widgets\" in the column menu and add a widget." useSimpleUiForNonRootPages: "Use simple UI for navigated pages" usedAsMinWidthWhenFlexible: "Minimum width will be used for this when the \"Auto-adjust width\" option is enabled" flexible: "Auto-adjust width" + enableSyncBetweenDevicesForProfiles: "Enable profile information sync between devices" _columns: main: "Main" widgets: "Widgets" @@ -2494,6 +2639,7 @@ _deck: mentions: "Mentions" direct: "Direct notes" roleTimeline: "Role Timeline" + chat: "Chat" _dialog: charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." @@ -2528,7 +2674,7 @@ _webhookSettings: testRemarks: "Click the button to the right of the switch to send a test Webhook with dummy data." _abuseReport: _notificationRecipient: - createRecipient: "Add a recipient for reports" + createRecipient: "Add recipient for reports" modifyRecipient: "Edit a recipient for reports" recipientType: "Notification type" _recipientType: @@ -2590,6 +2736,8 @@ _moderationLogTypes: deletePage: "Page deleted" deleteFlash: "Play deleted" deleteGalleryPost: "Gallery post deleted" + deleteChatRoom: "Deleted Chat Room" + updateProxyAccountDescription: "Update the description of the proxy account" _fileViewer: title: "File details" type: "File type" @@ -2603,10 +2751,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Make sure the distributor of this resource is trustworthy before installation." _plugin: title: "Do you want to install this plugin?" - metaTitle: "Plugin information" _theme: title: "Do you want to install this theme?" - metaTitle: "Theme information" _meta: base: "Base color scheme" _vendorInfo: @@ -2645,7 +2791,7 @@ _dataSaver: description: "Prevents images/videos from being loaded automatically. Hidden images/videos will be loaded when tapped." _avatar: title: "Avatar image" - description: "Stop avatar image animation. Animated images can be larger in file size than normal images, potentially leading to further reductions in data traffic." + description: "Stop avatar image animation. Animated images can be larger in file size than normal images, potentially leading to further reductions in data traffic." _urlPreview: title: "URL preview thumbnails" description: "URL preview thumbnail images will no longer be loaded." @@ -2737,7 +2883,7 @@ _roleSelectDialog: _customEmojisManager: _gridCommon: copySelectionRows: "Copy selected rows" - copySelectionRanges: "Copy selected ranges" + copySelectionRanges: "Copy selection" deleteSelectionRows: "Delete selected rows" deleteSelectionRanges: "Delete rows in the selection" searchSettings: "Search settings" @@ -2759,7 +2905,7 @@ _customEmojisManager: confirmImportEmojisTitle: "Import Emojis" confirmImportEmojisDescription: "Import {count} Emoji(s) received from the remote server. Please pay close attention to the license of the Emoji. Are you sure to continue?" _local: - tabTitleList: "List of registered Emojis" + tabTitleList: "Registered emojis" tabTitleRegister: "Emoji registration" _list: emojisNothing: "There are no registered Emojis." @@ -2773,7 +2919,7 @@ _customEmojisManager: confirmDeleteEmojisDescription: "Delete checked {count} Emoji(s). Are you sure to continue?" confirmResetDescription: "This will reset any changes you have made so far" confirmMovePageDesciption: "Changes have been made to the Emojis on this page.\nIf you leave the page without saving, all changes made on this page will be discarded." - dialogSelectRoleTitle: "Search by roll set in Emojis" + dialogSelectRoleTitle: "Search by role set in Emojis" _register: uploadSettingTitle: "Upload settings" uploadSettingDescription: "On this screen, you can configure the behavior when uploading Emojis." @@ -2807,8 +2953,8 @@ _selfXssPrevention: description2: "If you do not understand exactly what you are trying to paste, %cstop working right now and close this window." description3: "For more information, please refer to this. {link}" _followRequest: - recieved: "Received" - sent: "Sent" + recieved: "Received request" + sent: "Sent request" _remoteLookupErrors: _federationNotAllowed: title: "Unable to communicate with this server" @@ -2822,8 +2968,6 @@ _remoteLookupErrors: _responseInvalid: title: "Response is invalid" description: "It could communicate with this server, but the data obtained was incorrect." - _responseInvalidIdHostNotMatch: - description: "The domain of the entered URI differs from the domain of the final obtained URI. If you are looking up remote content through a third-party server, please look up again using a URI that can be obtained from the origin server." _noSuchObject: title: "Not found" description: "The requested resource was not found, please recheck the URI." @@ -2840,3 +2984,23 @@ _captcha: _unknown: title: "CAPTCHA error" text: "An unexpected error occurred." +_bootErrors: + title: "Failed to load" + serverError: "If the problem persists after waiting a moment and reloading, please contact the server administrator with the following Error ID." + solution: "The following may solve the problem." + solution1: "Update your browser and OS to the latest version" + solution2: "Disable ad blocker" + solution3: "Clear the browser cache" + solution4: "Set the dom.webaudio.enabled to true for Tor Browser" + otherOption: "Other options" + otherOption1: "Delete client settings and cache" + otherOption2: "Start the simple client" + otherOption3: "Launch the repair tool" +_search: + searchScopeAll: "All" + searchScopeLocal: "Local" + searchScopeServer: "Specific server" + searchScopeUser: "Specific user" + pleaseEnterServerHost: "Enter the server host" + pleaseSelectUser: "Select user" + serverHostPlaceholder: "Example: misskey.example.com" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 0b1411d84b..e14f37d1d6 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "¿Desea borrar \"{x}\"?" resetAreYouSure: "¿Desea reestablecer?" areYouSure: "¿Estás conforme?" saved: "Guardado" -messaging: "Chat" upload: "Subir" keepOriginalUploading: "Mantener la imagen original" keepOriginalUploadingDescription: "Mantener la versión original al cargar imágenes. Si está desactivado, el navegador generará imágenes para la publicación web en el momento de recargar la página" @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "Subir el fichero puede tardar un tiempo." explore: "Explorar" messageRead: "Ya leído" noMoreHistory: "El historial se ha acabado" -startMessaging: "Iniciar chat" +startChat: "Nuevo Chat" nUsersRead: "Leído por {n} personas" agreeTo: "De acuerdo con {0}" agree: "De acuerdo." @@ -425,6 +424,7 @@ antennaExcludeBots: "Excluir bots" antennaKeywordsDescription: "Separar con espacios es una declaración AND, separar con una linea nueva es una declaración OR" notifyAntenna: "Notificar nueva nota" withFileAntenna: "Sólo notas con archivos adjuntados" +excludeNotesInSensitiveChannel: "Excluir notas en canales sensibles" enableServiceworker: "Activar ServiceWorker" antennaUsersDescription: "Elegir nombres de usuarios separados por una linea nueva" caseSensitive: "Distinguir mayúsculas de minúsculas" @@ -491,8 +491,6 @@ noteOf: "Notas de {user}" quoteAttached: "Cita añadida" quoteQuestion: "¿Quiere añadir una cita?" attachAsFileQuestion: "El texto del portapapeles es demasiado grande ¿Desea adjuntarlo como archivo de texto?" -noMessagesYet: "Aún no hay chat" -newMessageExists: "Tienes un mensaje nuevo" onlyOneFileCanBeAttached: "Solo se puede añadir un archivo al mensaje" signinRequired: "Iniciar sesión" signinOrContinueOnRemote: "Para continuar, tendrá que ir a su servidor o registrarse e iniciar sesión en este servidor" @@ -605,6 +603,7 @@ descendingOrder: "Descendente" scratchpad: "Scratch pad" scratchpadDescription: "Scratchpad proporciona un entorno experimental para AiScript. Puede escribir, ejecutar y verificar los resultados que interactúan con Misskey." uiInspector: "Inspector de UI" +uiInspectorDescription: "Puedes visualizar una lista de elementos UI presentes en la memoria. Los componentes de la interfaz de usuario son generados por las funciones UI:C:" output: "Salida" script: "Script" disablePagesScript: "Deshabilitar AiScript en Páginas" @@ -693,9 +692,11 @@ regexpError: "Error de la expresión regular" regexpErrorDescription: "Ocurrió un error en la expresión regular en la linea {line} de las palabras muteadas {tab}" instanceMute: "Instancias silenciadas" userSaysSomething: "{name} dijo algo" +userSaysSomethingAbout: "{name} dijo algo sobre {word}" makeActive: "Activar" display: "Apariencia" copy: "Copiar" +copiedToClipboard: "Texto copiado al portapapeles" metrics: "Métricas" overview: "Resumen" logs: "Registros" @@ -861,6 +862,7 @@ administration: "Administrar" accounts: "Cuentas" switch: "Cambiar" noMaintainerInformationWarning: "No se ha establecido la información del administrador" +noInquiryUrlWarning: "No se ha guardado la URL de consulta." noBotProtectionWarning: "La protección contra los bots no está configurada" configure: "Configurar" postToGallery: "Crear una nueva publicación en la galería" @@ -925,6 +927,7 @@ followersVisibility: "Visibilidad de seguidores" continueThread: "Ver la continuación del hilo" deleteAccountConfirm: "La cuenta será borrada. ¿Está seguro?" incorrectPassword: "La contraseña es incorrecta" +incorrectTotp: "La contraseña de un solo uso es incorrecta o ha caducado." voteConfirm: "¿Confirma su voto a {choice}?" hide: "Ocultar" useDrawerReactionPickerForMobile: "Mostrar panel de reacciones en móviles" @@ -976,6 +979,7 @@ document: "Documento" numberOfPageCache: "Cantidad de páginas cacheadas" numberOfPageCacheDescription: "Al aumentar el número mejora la conveniencia pero tambien puede aumentar la carga y la memoria a usarse" logoutConfirm: "¿Cerrar sesión?" +logoutWillClearClientData: "Al cerrar la sesión, la información de configuración del cliente se borra del navegador. Para garantizar que la información de configuración se pueda restaurar al volver a iniciar sesión, active la copia de seguridad automática de la configuración." lastActiveDate: "Utilizado por última vez el" statusbar: "Barra de estado" pleaseSelect: "Selecciona una opción" @@ -1053,6 +1057,7 @@ thisPostMayBeAnnoyingHome: "Publicar en línea de tiempo 'Inicio'" thisPostMayBeAnnoyingCancel: "detener" thisPostMayBeAnnoyingIgnore: "Publicar de todos modos" collapseRenotes: "Colapsar renotas que ya hayas visto" +collapseRenotesDescription: "Contrae notas a las que ya has reaccionado o renotado " internalServerError: "Error interno del servidor" internalServerErrorDescription: "El servidor tuvo un error inesperado." copyErrorInfo: "Copiar detalles del error" @@ -1070,7 +1075,7 @@ reactionAcceptance: "Aceptación de reacciones" likeOnly: "Sólo 'me gusta'" likeOnlyForRemote: "Sólo reacciones de instancias remotas" nonSensitiveOnly: "Solo no sensible" -nonSensitiveOnlyForLocalLikeOnlyForRemote: "Sólo no contenido sensible (sólo me gusta en remote)" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "Sólo no contenido sensible (sólo me gusta en remoto)" rolesAssignedToMe: "Roles asignados a mí" resetPasswordConfirm: "¿Realmente quieres cambiar la contraseña?" sensitiveWords: "Palabras sensibles" @@ -1091,6 +1096,7 @@ retryAllQueuesConfirmTitle: "Desea ¿reintentar inmediatamente todas las colas?" retryAllQueuesConfirmText: "La carga del servidor está incrementándose temporalmente " enableChartsForRemoteUser: "Generar gráficas de usuarios remotos." enableChartsForFederatedInstances: "Generar gráficos de servidores remotos" +enableStatsForFederatedInstances: "Activar las estadísticas de las instancias remotas federadas" showClipButtonInNoteFooter: "Añadir \"Clip\" al menú de notas" reactionsDisplaySize: "Tamaño de las reacciones" limitWidthOfReaction: "Limitar ancho de las reacciones" @@ -1139,6 +1145,7 @@ preventAiLearningDescription: "Pedirle a las arañas (crawlers) no usar los text options: "Opción" specifyUser: "Especificar usuario" lookupConfirm: "¿Quiere informarse?" +openTagPageConfirm: "¿Quieres abrir la página de etiquetas?" specifyHost: "Especificar Host" failedToPreviewUrl: "No se pudo generar la vista previa" update: "Actualizar" @@ -1267,15 +1274,143 @@ useBackupCode: "Usar códigos de respaldo" launchApp: "Ejecutar la app" useNativeUIForVideoAudioPlayer: "Usar la interfaz del navegador cuando se reproduce audio y vídeo" keepOriginalFilename: "Mantener el nombre original del archivo" +keepOriginalFilenameDescription: "Si desactivas esta opción, los nombres de los archivos serán remplazados por una cadena de caracteres aleatoria cuando subas los archivos." noDescription: "No hay descripción" alwaysConfirmFollow: "Confirmar siempre cuando se sigue a alguien" inquiry: "Contacto" tryAgain: "Por favor , inténtalo de nuevo" +confirmWhenRevealingSensitiveMedia: "Confirmación cuando se revele contenido sensible" +sensitiveMediaRevealConfirm: "Esto puede contener contenido sensible. ¿Estás seguro/a de querer mostrarlo?" +createdLists: "Listas creadas" +createdAntennas: "Antenas creadas" +fromX: "De {x}" +genEmbedCode: "Obtener el código para incrustar" +noteOfThisUser: "Notas de este usuario" +clipNoteLimitExceeded: "No se pueden añadir más notas a este clip." performance: "Rendimiento" +modified: "Modificado" +discard: "Descartar" +thereAreNChanges: "Hay {n} cambio(s)" +signinWithPasskey: "Iniciar sesión con clave de acceso" unknownWebAuthnKey: "Esto no se ha registrado llave maestra." +passkeyVerificationFailed: "La verificación de la clave de acceso ha fallado." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificación de la clave de acceso ha sido satisfactoria pero se ha deshabilitado el inicio de sesión sin contraseña." messageToFollower: "Mensaje a seguidores" +target: "Para" +testCaptchaWarning: "Esta función está pensada para probar CAPTCHAs.No utilizar en un entorno de producción." +prohibitedWordsForNameOfUser: "Palabras prohibidas para nombres de usuario" +prohibitedWordsForNameOfUserDescription: "Si alguna de las cadenas de esta lista está incluida en el nombre del usuario, el nombre será denegado. Los usuarios con privilegios de moderador no se ven afectados por esta restricción." +yourNameContainsProhibitedWords: "Tu nombre contiene palabras prohibidas" +yourNameContainsProhibitedWordsDescription: "Si deseas usar este nombre, por favor contacta con tu administrador/a de tu servidor" +thisContentsAreMarkedAsSigninRequiredByAuthor: " Establecido por el autor: requiere iniciar sesión para ver" +lockdown: "Bloqueo" +pleaseSelectAccount: "Seleccione una cuenta, por favor." +availableRoles: "Roles disponibles " +acknowledgeNotesAndEnable: "Activar después de comprender las precauciones" federationSpecified: "Este servidor opera en una federación de listas blancas. No puede interactuar con otros servidores que no sean los especificados por el administrador." federationDisabled: "La federación está desactivada en este servidor. No puede interactuar con usuarios de otros servidores" +confirmOnReact: "Confirmar la reacción" +reactAreYouSure: "¿Quieres añadir una reacción «{emoji}»?" +markAsSensitiveConfirm: "¿Desea establecer este medio multimedia(Imagen,vídeo...) como sensible?" +unmarkAsSensitiveConfirm: "¿Desea eliminar la designación de sensible para este adjunto?" +preferences: "Preferencias" +accessibility: "Accesibilidad" +preferencesProfile: "Configuración del perfil" +copyPreferenceId: "Copiar ID de la configuración" +resetToDefaultValue: "Revertir a valor predeterminado" +overrideByAccount: "Anulado por la cuenta" +untitled: "Sin título" +noName: "No hay nombre." +skip: "Saltar" +restore: "Restaurar" +syncBetweenDevices: "Sincronizar entre dispositivos" +preferenceSyncConflictTitle: "Los valores configurados existen en el servidor." +preferenceSyncConflictText: "Los ajustes de sincronización activados guardarán sus valores en el servidor. Sin embargo, hay valores existentes en el servidor. ¿Qué conjunto de valores desea sobrescribir?" +preferenceSyncConflictChoiceServer: "Valores de configuración del servidor" +preferenceSyncConflictChoiceDevice: "Valor configurado en el dispositivo" +paste: "Pegar" +emojiPalette: "Paleta emoji" +postForm: "Formulario" +information: "Información" +chat: "Chat" +migrateOldSettings: "Migrar la configuración anterior" +right: "Derecha" +bottom: "Abajo" +top: "Arriba" +embed: "Insertar" +settingsMigrating: "La configuración está siendo migrada, por favor espera un momento... (También puedes migrar manualmente más tarde yendo a Ajustes otros migrar configuración antigua" +readonly: "Solo Lectura" +goToDeck: "Volver al Deck" +federationJobs: "Trabajos de Federación" +_chat: + noMessagesYet: "Aún no hay mensajes" + newMessage: "Mensajes nuevos" + individualChat: "Chat individual" + individualChat_description: "Mantén una conversación privada con otra persona." + roomChat: "Sala de Chat" + roomChat_description: "Una sala de chat que puede tener varias personas.\nTambién puedes invitar a personas que no permiten chats privados si aceptan la invitación." + createRoom: "Crear sala" + inviteUserToChat: "Invitar usuarios para empezar a chatear" + yourRooms: "Salas creadas" + joiningRooms: "Salas que te has unido" + invitations: "Invitar" + noInvitations: "No hay invitación." + history: "Historial" + noHistory: "No hay datos en el historial" + noRooms: "Sala no encontrada" + inviteUser: "Invitar usuarios" + sentInvitations: "Invitaciones enviadas" + join: "Unirse" + ignore: "Ignorar" + leave: "Dejar sala" + members: "Miembros" + searchMessages: "Buscar mensajes" + home: "Inicio" + send: "Enviar" + newline: "Nueva línea" + muteThisRoom: "Silenciar esta sala" + deleteRoom: "Borrar sala" + chatNotAvailableForThisAccountOrServer: "El chat no está habilitado en este servidor ni para esta cuenta." + chatIsReadOnlyForThisAccountOrServer: "El chat es de sólo lectura en esta instancia o esta cuenta. No puedes escribir nuevos mensajes ni crear/unirte a salas de chat." + chatNotAvailableInOtherAccount: "La función de chat está desactivada para el otro usuario." + cannotChatWithTheUser: "No se puede iniciar un chat con este usuario" + cannotChatWithTheUser_description: "El chat no está disponible o la otra parte no ha habilitado el chat." + chatWithThisUser: "Chatear" + thisUserAllowsChatOnlyFromFollowers: "Este usuario sólo acepta chats de seguidores." + thisUserAllowsChatOnlyFromFollowing: "Este usuario sólo acepta chats de los usuarios a los que sigue." + thisUserAllowsChatOnlyFromMutualFollowing: "Este usuario sólo acepta chats de usuarios que son seguidores mutuos." + thisUserNotAllowedChatAnyone: "Este usuario no acepta chats de nadie." + chatAllowedUsers: "A quién permitir chatear." + chatAllowedUsers_note: "Puedes chatear con cualquier persona a la que hayas enviado un mensaje de chat, independientemente de esta configuración." + _chatAllowedUsers: + everyone: "Todos" + followers: "Sólo sus propios seguidores." + following: "Solo usuarios que sigues" + mutual: "Solo seguidores mutuos" + none: "Nadie" +_emojiPalette: + palettes: "Paleta\n" + enableSyncBetweenDevicesForPalettes: "Activar la sincronización de paletas entre dispositivos" +_settings: + api: "API" + webhook: "Webhook" + timelineAndNote: "Líneas del tiempo y notas" + makeEveryTextElementsSelectable_description: "Activar esta opción puede reducir la usabilidad en algunas situaciones." + useStickyIcons: "Hacer que los iconos te sigan cuando desplaces" + showNavbarSubButtons: "Mostrar los sub-botones en la barra de navegación." + ifOn: "Si está activado" + enableSyncThemesBetweenDevices: "Sincronizar los temas instalados entre dispositivos." + _chat: + showSenderName: "Mostrar el nombre del remitente" + sendOnEnter: "Intro para enviar" +_preferencesProfile: + profileName: "Nombre de perfil" + profileNameDescription: "Establece un nombre que identifique al dispositivo" + profileNameDescription2: "Por ejemplo: \"PC Principal\",\"Teléfono\"" +_preferencesBackup: + autoBackup: "Respaldo automático" + restoreFromBackup: "Restaurar desde copia de seguridad" + noBackupsFoundTitle: "No se encontró una copia de seguridad" _accountSettings: requireSigninToViewContents: "Se requiere iniciar sesión para ver el contenido" requireSigninToViewContentsDescription1: "Requiere iniciar sesión para ver todas las notas y otros contenidos que hayas creado. Se espera que esto evite que los rastreadores recopilen información." @@ -1935,7 +2070,6 @@ _theme: header: "Cabezal" navBg: "Fondo de la barra lateral" navFg: "Texto de la barra lateral" - navHoverFg: "Texto de la barra lateral (hover)" navActive: "Texto de la barra lateral (activo)" navIndicator: "Indicador de la barra lateral" link: "Vínculo" @@ -1958,11 +2092,8 @@ _theme: buttonHoverBg: "Fondo de botón (hover)" inputBorder: "Borde de los campos de entrada" driveFolderBg: "Fondo de capeta del drive" - wallpaperOverlay: "Transparencia del fondo de pantalla" badge: "Medalla" messageBg: "Fondo de chat" - accentDarken: "Acento (oscuro)" - accentLighten: "Acento (claro)" fgHighlighted: "Texto resaltado" _sfx: note: "Notas" @@ -2114,6 +2245,7 @@ _permissions: "read:clip-favorite": "Ver los clips que me gustan" "read:federation": "Ver instancias federadas" "write:report-abuse": "Crear reportes de usuario" + "write:chat": "Administrar chat" _auth: shareAccessTitle: "Permisos de la aplicación" shareAccess: "¿Desea permitir el acceso a la cuenta \"{name}\"?" @@ -2169,6 +2301,7 @@ _widgets: chooseList: "Seleccione una lista" clicker: "Cliqueador" birthdayFollowings: "Hoy cumplen años" + chat: "Chat" _cw: hide: "Ocultar" show: "Ver más" @@ -2416,6 +2549,7 @@ _deck: mentions: "Menciones" direct: "Notas directas" roleTimeline: "Linea de tiempo del rol" + chat: "Chat" _dialog: charactersExceeded: "¡Has excedido el límite de caracteres! Actualmente {current} de {max}." charactersBelow: "¡Estás por debajo del límite de caracteres! Actualmente {current} de {min}." @@ -2495,10 +2629,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Asegúrate de que el distribuidor de este recurso es de confianza antes de proceder a la instalación." _plugin: title: "¿Quieres instalar este plugin?" - metaTitle: "Información del plugin" _theme: title: "¿Quieres instalar este tema?" - metaTitle: "Información del tema" _meta: base: "Esquema de color base" _vendorInfo: @@ -2560,6 +2692,13 @@ _mediaControls: pip: "Picture in Picture" playbackRate: "Velocidad de reproducción" loop: "Reproducción en bucle" +_followRequest: + recieved: "Petición de seguimiento recibida" + sent: "Petición de seguimiento enviada" _remoteLookupErrors: _noSuchObject: title: "No se encuentra" +_search: + searchScopeAll: "Todo" + searchScopeLocal: "Local" + searchScopeUser: "Especificar usuario" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index ccfd462a76..3a6f520ae6 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -277,7 +277,6 @@ deleteAreYouSure: "Êtes-vous sûr·e de vouloir supprimer « {x} » ?" resetAreYouSure: "Voulez-vous réinitialiser ?" areYouSure: "Êtes-vous sûr·e ?" saved: "Enregistré" -messaging: "Discuter" upload: "Téléverser" keepOriginalUploading: "Garder l’image d’origine" keepOriginalUploadingDescription: "Conserve la version originale lors du téléchargement d'images. S'il est désactivé, le navigateur génère l'image pour la publication web lors du téléchargement." @@ -290,7 +289,6 @@ uploadFromUrlMayTakeTime: "Le téléversement de votre fichier peut prendre un c explore: "Découvrir" messageRead: "Lu" noMoreHistory: "Il n’y a plus d’historique" -startMessaging: "Commencer à discuter" nUsersRead: "Lu par {n} personnes" agreeTo: "J’accepte {0}" agree: "Accepter" @@ -477,8 +475,6 @@ retype: "Confirmation" noteOf: "Notes de {user}" quoteAttached: "Avec citation" quoteQuestion: "Souhaitez-vous ajouter une citation ?" -noMessagesYet: "Pas encore de discussion" -newMessageExists: "Vous avez un nouveau message" onlyOneFileCanBeAttached: "Vous ne pouvez joindre qu’un seul fichier au message" signinRequired: "Veuillez vous connecter" invitations: "Invitations" @@ -1277,6 +1273,14 @@ prohibitedWordsForNameOfUser: "Mots interdits pour les noms d'utilisateur·rices lockdown: "Verrouiller" pleaseSelectAccount: "Sélectionner un compte" availableRoles: "Rôles disponibles" +postForm: "Formulaire de publication" +information: "Informations" +_chat: + invitations: "Inviter" + noHistory: "Pas d'historique" + members: "Membres" + home: "Principal" + send: "Envoyer" _abuseUserReport: forward: "Transférer" forwardDescription: "Transférer le signalement vers une instance distante en tant qu'anonyme." @@ -1812,7 +1816,6 @@ _theme: header: "Entête" navBg: "Fond de la barre latérale" navFg: "Texte de la barre latérale" - navHoverFg: "Texte de la barre latérale (survolé)" navActive: "Texte de la barre latérale (actif)" navIndicator: "Indicateur de barre latérale" link: "Lien" @@ -1835,11 +1838,8 @@ _theme: buttonHoverBg: "Arrière-plan du bouton (survolé)" inputBorder: "Cadre de la zone de texte" driveFolderBg: "Arrière-plan du dossier de disque" - wallpaperOverlay: "Superposition de fond d'écran" badge: "Badge" messageBg: "Arrière plan de la discussion" - accentDarken: "Plus sombre" - accentLighten: "Plus clair" fgHighlighted: "Texte mis en évidence" _sfx: note: "Nouvelle note" @@ -1949,6 +1949,7 @@ _permissions: "write:admin:unsuspend-user": "Lever la suspension d'un utilisateur" "write:admin:meta": "Gérer les métadonnées de l'instance" "write:admin:roles": "Gérer les rôles" + "write:chat": "Gérer les discussions" _auth: shareAccess: "Autoriser \"{name}\" à accéder à votre compte ?" shareAccessAsk: "Voulez-vous vraiment autoriser cette application à accéder à votre compte?" @@ -2294,10 +2295,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Veuillez confirmer que le distributeur est fiable avant l'installation." _plugin: title: "Voulez-vous installer cette extension ?" - metaTitle: "Informations sur l'extension" _theme: title: "Voulez-vous installer ce thème ?" - metaTitle: "Informations sur le thème" _meta: base: "Palette de couleurs de base" _vendorInfo: @@ -2364,3 +2363,7 @@ _embedCodeGen: _remoteLookupErrors: _noSuchObject: title: "Non trouvé" +_search: + searchScopeAll: "Tous" + searchScopeLocal: "Local" + searchScopeUser: "Spécifier l'utilisateur·rice" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 7be56b1494..22ccbf153a 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -280,7 +280,6 @@ deleteAreYouSure: "Apakah kamu yakin ingin menghapus \"{x}\"?" resetAreYouSure: "Yakin mau atur ulang?" areYouSure: "Apakah kamu yakin?" saved: "Telah disimpan" -messaging: "Pesan" upload: "Unggah" keepOriginalUploading: "Simpan gambar asli" keepOriginalUploadingDescription: "Simpan gambar yang diunggah sebagaimana gambar aslinya. Bila dimatikan, versi tampilan web akan dihasilkan pada saat diunggah." @@ -293,7 +292,6 @@ uploadFromUrlMayTakeTime: "Membutuhkan beberapa waktu hingga pengunggahan selesa explore: "Jelajahi" messageRead: "Telah dibaca" noMoreHistory: "Tidak ada sejarah lagi" -startMessaging: "Mulai mengirim pesan" nUsersRead: "Dibaca oleh {n}" agreeTo: "Saya setuju kepada {0}" agree: "Setuju" @@ -481,8 +479,6 @@ noteOf: "Catatan milik {user}" quoteAttached: "Dikutip" quoteQuestion: "Apakah kamu ingin menambahkan kutipan?" attachAsFileQuestion: "Teks dalam papan klip terlalu panjang. Apakah kamu ingin melampirkannya sebagai berkas teks?" -noMessagesYet: "Tidak ada pesan" -newMessageExists: "Kamu mendapatkan pesan baru" onlyOneFileCanBeAttached: "Kamu hanya dapat melampirkan satu berkas ke dalam pesan" signinRequired: "Silahkan login" invitations: "Undangan" @@ -1261,6 +1257,16 @@ performance: "Kinerja" modified: "Diubah" thereAreNChanges: "Ada {n} perubahan" prohibitedWordsForNameOfUser: "Kata yang dilarang untuk nama pengguna" +postForm: "Buat catatan" +information: "Informasi" +_chat: + invitations: "Undang" + noHistory: "Tidak ada riwayat" + members: "Anggota" + home: "Beranda" + send: "Kirim" +_settings: + webhook: "Webhook" _abuseUserReport: accept: "Setuju" reject: "Tolak" @@ -1925,7 +1931,6 @@ _theme: header: "Header" navBg: "Latar belakang bilah samping" navFg: "Teks bilah samping" - navHoverFg: "Teks bilah samping (Mengambang)" navActive: "Teks bilah samping (Aktif)" navIndicator: "Indikator bilah samping" link: "Tautan" @@ -1948,11 +1953,8 @@ _theme: buttonHoverBg: "Latar belakang tombol (Mengambang)" inputBorder: "Batas bidang masukan" driveFolderBg: "Latar belakang folder drive" - wallpaperOverlay: "Lapisan wallpaper" badge: "Lencana" messageBg: "Latar belakang obrolan" - accentDarken: "Aksen (Gelap)" - accentLighten: "Aksen (Terang)" fgHighlighted: "Teks yang disorot" _sfx: note: "Catatan" @@ -2105,6 +2107,7 @@ _permissions: "read:clip-favorite": "Lihat klip yang difavoritkan" "read:federation": "Mendapatkan data federasi" "write:report-abuse": "Melaporkan pelanggaran" + "write:chat": "Buat atau hapus obrolan" _auth: shareAccessTitle: "Mendapatkan ijin akses aplikasi" shareAccess: "Apakah kamu ingin mengijinkan \"{name}\" untuk mengakses akun ini?" @@ -2489,10 +2492,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Pastikan sumber dari sumber daya ini terpercaya sebelum melakukan pemasangan." _plugin: title: "Apakah kamu ingin memasang plugin ini?" - metaTitle: "Informasi plugin" _theme: title: "Apakah kamu ingin memasang tema ini?" - metaTitle: "Informasi tema" _meta: base: "Skema warna dasar" _vendorInfo: @@ -2610,3 +2611,7 @@ _mediaControls: _remoteLookupErrors: _noSuchObject: title: "Tidak dapat ditemukan" +_search: + searchScopeAll: "Semua" + searchScopeLocal: "Lokal" + searchScopeUser: "Pengguna spesifik" diff --git a/locales/index.d.ts b/locales/index.d.ts index 327327df0b..f1aa474b46 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1174,10 +1174,6 @@ export interface Locale extends ILocale { * 保存しました */ "saved": string; - /** - * チャット - */ - "messaging": string; /** * アップロード */ @@ -1227,9 +1223,9 @@ export interface Locale extends ILocale { */ "noMoreHistory": string; /** - * チャットを開始 + * チャットを始める */ - "startMessaging": string; + "startChat": string; /** * {n}人が読みました */ @@ -1403,7 +1399,7 @@ export interface Locale extends ILocale { */ "inputNewFileName": string; /** - * Enter new alt text + * 新しいキャプションを入力してください */ "inputNewDescription": string; /** @@ -1718,6 +1714,10 @@ export interface Locale extends ILocale { * ファイルが添付されたノートのみ */ "withFileAntenna": string; + /** + * センシティブなチャンネルのノートを除外 + */ + "excludeNotesInSensitiveChannel": string; /** * ブラウザへのプッシュ通知を有効にする */ @@ -1982,14 +1982,6 @@ export interface Locale extends ILocale { * The text in clipboard is long. Would you like to attach it as a text file? */ "attachAsFileQuestion": string; - /** - * まだチャットはありません - */ - "noMessagesYet": string; - /** - * 新しいメッセージがあります - */ - "newMessageExists": string; /** * メッセージに添付できるファイルはひとつです */ @@ -2455,7 +2447,7 @@ export interface Locale extends ILocale { */ "disablePagesScript": string; /** - * リモートユーザー情報の更新 + * Refresh remote data */ "updateRemoteUser": string; /** @@ -2603,11 +2595,11 @@ export interface Locale extends ILocale { */ "description": string; /** - * Add alt text + * キャプションを付ける */ "describeFile": string; /** - * Enter alt text + * キャプションを入力 */ "enterFileDescription": string; /** @@ -2810,6 +2802,10 @@ export interface Locale extends ILocale { * コピー */ "copy": string; + /** + * クリップボードにコピーされました + */ + "copiedToClipboard": string; /** * メトリクス */ @@ -3939,6 +3935,10 @@ export interface Locale extends ILocale { * ログアウトしますか? */ "logoutConfirm": string; + /** + * ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。 + */ + "logoutWillClearClientData": string; /** * 最終利用日時 */ @@ -4084,7 +4084,7 @@ export interface Locale extends ILocale { */ "windowRestore": string; /** - * Alt text + * キャプション */ "caption": string; /** @@ -4972,7 +4972,7 @@ export interface Locale extends ILocale { */ "disableStreamingTimeline": string; /** - * 通知をグルーピングして表示する + * 通知をグルーピング */ "useGroupedNotifications": string; /** @@ -5255,6 +5255,516 @@ export interface Locale extends ILocale { * このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。 */ "federationDisabled": string; + /** + * リアクションする際に確認する + */ + "confirmOnReact": string; + /** + * " {emoji} " をリアクションしますか? + */ + "reactAreYouSure": ParameterizedString<"emoji">; + /** + * このメディアをセンシティブとして設定しますか? + */ + "markAsSensitiveConfirm": string; + /** + * このメディアのセンシティブ指定を解除しますか? + */ + "unmarkAsSensitiveConfirm": string; + /** + * 環境設定 + */ + "preferences": string; + /** + * アクセシビリティ + */ + "accessibility": string; + /** + * 設定のプロファイル + */ + "preferencesProfile": string; + /** + * 設定IDをコピー + */ + "copyPreferenceId": string; + /** + * 初期値に戻す + */ + "resetToDefaultValue": string; + /** + * アカウントで上書き + */ + "overrideByAccount": string; + /** + * 無題 + */ + "untitled": string; + /** + * 名前はありません + */ + "noName": string; + /** + * スキップ + */ + "skip": string; + /** + * 復元 + */ + "restore": string; + /** + * デバイス間で同期 + */ + "syncBetweenDevices": string; + /** + * サーバーに設定値が存在します + */ + "preferenceSyncConflictTitle": string; + /** + * 同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか? + */ + "preferenceSyncConflictText": string; + /** + * サーバーの設定値 + */ + "preferenceSyncConflictChoiceServer": string; + /** + * デバイスの設定値 + */ + "preferenceSyncConflictChoiceDevice": string; + /** + * 同期の有効化をキャンセル + */ + "preferenceSyncConflictChoiceCancel": string; + /** + * ペースト + */ + "paste": string; + /** + * 絵文字パレット + */ + "emojiPalette": string; + /** + * 投稿フォーム + */ + "postForm": string; + /** + * 文字数 + */ + "textCount": string; + /** + * 情報 + */ + "information": string; + /** + * チャット + */ + "chat": string; + /** + * 旧設定情報を移行 + */ + "migrateOldSettings": string; + /** + * 通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。 + */ + "migrateOldSettings_description": string; + /** + * 圧縮 + */ + "compress": string; + /** + * 右 + */ + "right": string; + /** + * 下 + */ + "bottom": string; + /** + * 上 + */ + "top": string; + /** + * 埋め込み + */ + "embed": string; + /** + * 設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます) + */ + "settingsMigrating": string; + /** + * 読み取り専用 + */ + "readonly": string; + /** + * デッキへ戻る + */ + "goToDeck": string; + /** + * 連合ジョブ + */ + "federationJobs": string; + /** + * ドライブでは、過去にアップロードしたファイルの一覧が表示されます。
+ * ノートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。
+ * ファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。
+ * フォルダを作って整理することもできます。 + */ + "driveAboutTip": string; + "_chat": { + /** + * まだメッセージはありません + */ + "noMessagesYet": string; + /** + * 新しいメッセージ + */ + "newMessage": string; + /** + * 個人チャット + */ + "individualChat": string; + /** + * 特定ユーザーとの一対一のチャットができます。 + */ + "individualChat_description": string; + /** + * ルームチャット + */ + "roomChat": string; + /** + * 複数人でのチャットができます。 + * また、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。 + */ + "roomChat_description": string; + /** + * ルームを作成 + */ + "createRoom": string; + /** + * ユーザーを招待してチャットを始めましょう + */ + "inviteUserToChat": string; + /** + * 作成したルーム + */ + "yourRooms": string; + /** + * 参加中のルーム + */ + "joiningRooms": string; + /** + * 招待 + */ + "invitations": string; + /** + * 招待はありません + */ + "noInvitations": string; + /** + * 履歴 + */ + "history": string; + /** + * 履歴はありません + */ + "noHistory": string; + /** + * ルームはありません + */ + "noRooms": string; + /** + * ユーザーを招待 + */ + "inviteUser": string; + /** + * 送信した招待 + */ + "sentInvitations": string; + /** + * 参加 + */ + "join": string; + /** + * 無視 + */ + "ignore": string; + /** + * ルームから退出 + */ + "leave": string; + /** + * メンバー + */ + "members": string; + /** + * メッセージを検索 + */ + "searchMessages": string; + /** + * ホーム + */ + "home": string; + /** + * 送信 + */ + "send": string; + /** + * 改行 + */ + "newline": string; + /** + * このルームをミュート + */ + "muteThisRoom": string; + /** + * ルームを削除 + */ + "deleteRoom": string; + /** + * このサーバー、またはこのアカウントでチャットは有効化されていません。 + */ + "chatNotAvailableForThisAccountOrServer": string; + /** + * このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。 + */ + "chatIsReadOnlyForThisAccountOrServer": string; + /** + * 相手のアカウントでチャット機能が使えない状態になっています。 + */ + "chatNotAvailableInOtherAccount": string; + /** + * このユーザーとのチャットを開始できません + */ + "cannotChatWithTheUser": string; + /** + * チャットが使えない状態になっているか、相手がチャットを開放していません。 + */ + "cannotChatWithTheUser_description": string; + /** + * チャットする + */ + "chatWithThisUser": string; + /** + * このユーザーはフォロワーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromFollowers": string; + /** + * このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromFollowing": string; + /** + * このユーザーは相互フォローのユーザーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromMutualFollowing": string; + /** + * このユーザーは誰からもチャットを受け付けていません。 + */ + "thisUserNotAllowedChatAnyone": string; + /** + * チャットを許可する相手 + */ + "chatAllowedUsers": string; + /** + * 自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。 + */ + "chatAllowedUsers_note": string; + "_chatAllowedUsers": { + /** + * 誰でも + */ + "everyone": string; + /** + * 自分のフォロワーのみ + */ + "followers": string; + /** + * 自分がフォローしているユーザーのみ + */ + "following": string; + /** + * 相互フォローのユーザーのみ + */ + "mutual": string; + /** + * 誰も許可しない + */ + "none": string; + }; + }; + "_emojiPalette": { + /** + * パレット + */ + "palettes": string; + /** + * パレットのデバイス間同期を有効にする + */ + "enableSyncBetweenDevicesForPalettes": string; + /** + * メインで使用するパレット + */ + "paletteForMain": string; + /** + * リアクションで使用するパレット + */ + "paletteForReaction": string; + }; + "_settings": { + /** + * ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。 + */ + "driveBanner": string; + /** + * プラグインを利用するとクライアントの機能を拡張することができます。プラグインのインストール、個別の設定と管理が行えます。 + */ + "pluginBanner": string; + /** + * サーバーからの受信する通知の種類と範囲や、プッシュ通知の設定が行えます。 + */ + "notificationsBanner": string; + /** + * API + */ + "api": string; + /** + * Webhook + */ + "webhook": string; + /** + * サービス連携 + */ + "serviceConnection": string; + /** + * 外部のアプリ・サービスと連携するためのアクセストークンやWebhookの管理と設定が行えます。 + */ + "serviceConnectionBanner": string; + /** + * アカウントのデータ + */ + "accountData": string; + /** + * アカウントデータのアーカイブをエクスポート/インポートして管理できます。 + */ + "accountDataBanner": string; + /** + * 非表示にするコンテンツの設定や、特定のユーザーからのアクションを制限する設定と管理を行えます。 + */ + "muteAndBlockBanner": string; + /** + * クライアントの視覚や動作に関するパーソナライズを行い、より最適に使用できるように設定できます。 + */ + "accessibilityBanner": string; + /** + * コンテンツの公開範囲、見つけやすさ、フォローの承認制などアカウントのプライバシーに関する設定を行えます。 + */ + "privacyBanner": string; + /** + * パスワード、ログイン方法、認証アプリ、パスキーなどアカウントのセキュリティに関する設定を行えます。 + */ + "securityBanner": string; + /** + * 好みに応じた、クライアントの全体的な動作の設定が行えます。 + */ + "preferencesBanner": string; + /** + * 好みに応じた、クライアントの見た目・表示方法に関する設定が行えます。 + */ + "appearanceBanner": string; + /** + * クライアントで再生するサウンドの設定が行えます。 + */ + "soundsBanner": string; + /** + * タイムラインとノート + */ + "timelineAndNote": string; + /** + * 全てのテキスト要素を選択可能にする + */ + "makeEveryTextElementsSelectable": string; + /** + * 有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。 + */ + "makeEveryTextElementsSelectable_description": string; + /** + * アイコンをスクロールに追従させる + */ + "useStickyIcons": string; + /** + * ナビゲーションバーに副ボタンを表示 + */ + "showNavbarSubButtons": string; + /** + * オンのとき + */ + "ifOn": string; + /** + * オフのとき + */ + "ifOff": string; + /** + * デバイス間でインストールしたテーマを同期 + */ + "enableSyncThemesBetweenDevices": string; + "_chat": { + /** + * 送信者の名前を表示 + */ + "showSenderName": string; + /** + * Enterで送信 + */ + "sendOnEnter": string; + }; + }; + "_preferencesProfile": { + /** + * プロファイル名 + */ + "profileName": string; + /** + * このデバイスを識別する名前を設定してください。 + */ + "profileNameDescription": string; + /** + * 例: 「メインPC」、「スマホ」など + */ + "profileNameDescription2": string; + }; + "_preferencesBackup": { + /** + * 自動バックアップ + */ + "autoBackup": string; + /** + * バックアップから復元 + */ + "restoreFromBackup": string; + /** + * バックアップが見つかりませんでした + */ + "noBackupsFoundTitle": string; + /** + * 自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。 + */ + "noBackupsFoundDescription": string; + /** + * 復元するバックアップを選択してください + */ + "selectBackupToRestore": string; + /** + * 自動バックアップを有効にするにはプロファイル名の設定が必要です。 + */ + "youNeedToNameYourProfileToEnableAutoBackup": string; + /** + * このデバイスで設定の自動バックアップは有効になっていません。 + */ + "autoPreferencesBackupIsNotEnabledForThisDevice": string; + /** + * 設定のバックアップが見つかりました + */ + "backupFound": string; + }; "_accountSettings": { /** * コンテンツの表示にログインを必須にする @@ -5292,6 +5802,10 @@ export interface Locale extends ILocale { * リモートサーバーに連合されたノートには効果が及ばない場合があります。 */ "mayNotEffectForFederatedNotes": string; + /** + * これらの制限は簡易的なものです。リモートサーバーでの閲覧やモデレーション時など、一部のシチュエーションでは適用されない場合があります。 + */ + "mayNotEffectSomeSituations": string; /** * 指定した時間を経過しているノート */ @@ -6902,6 +7416,14 @@ export interface Locale extends ILocale { * 数値が大きいほどUI上で先頭に表示されます。 */ "descriptionOfDisplayOrder": string; + /** + * アサイン状態を移行先アカウントにも引き継ぐ + */ + "preserveAssignmentOnMoveAccount": string; + /** + * オンにすると、このロールが付与されたアカウントが移行された際に、移行先アカウントにもこのロールが引き継がれるようになります。 + */ + "preserveAssignmentOnMoveAccount_description": string; /** * モデレーターのメンバー編集を許可 */ @@ -6973,6 +7495,10 @@ export interface Locale extends ILocale { * ドライブ容量 */ "driveCapacity": string; + /** + * アップロード可能な最大ファイルサイズ + */ + "maxFileSize": string; /** * ファイルにNSFWを常に付与 */ @@ -7057,6 +7583,10 @@ export interface Locale extends ILocale { * リストのインポートを許可 */ "canImportUserLists": string; + /** + * チャットを許可 + */ + "chatAvailability": string; /** * Can view the bubble timeline */ @@ -7069,6 +7599,10 @@ export interface Locale extends ILocale { * Maximum number of scheduled notes */ "scheduleNoteMax": string; + /** + * Can appear in trending notes / users + */ + "canTrend": string; }; "_condition": { /** @@ -7147,7 +7681,59 @@ export interface Locale extends ILocale { * ~ではない */ "not": string; + /** + * Is from a specific instance + */ + "isFromInstance": string; + /** + * Hostname (case-insensitive) + */ + "isFromInstanceHost": string; + /** + * Match subdomains + */ + "isFromInstanceSubdomains": string; + /** + * User is from a bubble instance + */ + "fromBubbleInstance": string; + /** + * Has X or fewer local followers + */ + "localFollowersLessThanOrEq": string; + /** + * Has X or more local followers + */ + "localFollowersMoreThanOrEq": string; + /** + * Follows X or fewer local accounts + */ + "localFollowingLessThanOrEq": string; + /** + * Follows X or more local accounts + */ + "localFollowingMoreThanOrEq": string; + /** + * Has X or fewer remote followers + */ + "remoteFollowersLessThanOrEq": string; + /** + * Has X or more remote followers + */ + "remoteFollowersMoreThanOrEq": string; + /** + * Follows X or fewer remote accounts + */ + "remoteFollowingLessThanOrEq": string; + /** + * Follows X or more remote accounts + */ + "remoteFollowingMoreThanOrEq": string; }; + /** + * This condition may be incorrect for remote users. + */ + "remoteDataWarning": string; }; "_sensitiveMediaDetection": { /** @@ -7706,6 +8292,10 @@ export interface Locale extends ILocale { * 標準のテーマ */ "builtinThemes": string; + /** + * サーバーのテーマ + */ + "instanceTheme": string; /** * そのテーマは既にインストールされています */ @@ -7824,23 +8414,19 @@ export interface Locale extends ILocale { */ "header": string; /** - * サイドバーの背景 + * ナビゲーションバーの背景 */ "navBg": string; /** - * サイドバーの文字 + * ナビゲーションバーの文字 */ "navFg": string; /** - * サイドバー文字(ホバー) - */ - "navHoverFg": string; - /** - * サイドバー文字(アクティブ) + * ナビゲーションバー文字(アクティブ) */ "navActive": string; /** - * サイドバーのインジケーター + * ナビゲーションバーのインジケーター */ "navIndicator": string; /** @@ -7923,10 +8509,6 @@ export interface Locale extends ILocale { * ドライブフォルダーの背景 */ "driveFolderBg": string; - /** - * 壁紙のオーバーレイ - */ - "wallpaperOverlay": string; /** * バッジ */ @@ -7935,14 +8517,6 @@ export interface Locale extends ILocale { * チャットの背景 */ "messageBg": string; - /** - * アクセント (暗め) - */ - "accentDarken": string; - /** - * アクセント (明るめ) - */ - "accentLighten": string; /** * 強調された文字 */ @@ -7966,6 +8540,10 @@ export interface Locale extends ILocale { * リアクション選択時 */ "reaction": string; + /** + * チャットのメッセージ + */ + "chatMessage": string; }; "_soundSettings": { /** @@ -8538,6 +9116,14 @@ export interface Locale extends ILocale { * 違反を報告する */ "write:report-abuse": string; + /** + * チャットを操作する + */ + "write:chat": string; + /** + * チャットを閲覧する + */ + "read:chat": string; /** * Approve new users */ @@ -8551,7 +9137,7 @@ export interface Locale extends ILocale { */ "write:admin:nsfw-user": string; /** - * Mark users an not NSFW + * Mark users as not NSFW */ "write:admin:unnsfw-user": string; /** @@ -8804,6 +9390,10 @@ export interface Locale extends ILocale { * 今日誕生日のユーザー */ "birthdayFollowings": string; + /** + * チャット + */ + "chat": string; /** * Search */ @@ -9064,6 +9654,14 @@ export interface Locale extends ILocale { * フォローを承認制にしている場合、フォローリクエストを許可した時に表示されます。 */ "followedMessageDescriptionForLockedAccount": string; + /** + * Update avatar + */ + "updateAvatar": string; + /** + * Remove avatar + */ + "removeAvatar": string; /** * Update banner */ @@ -9568,6 +10166,10 @@ export interface Locale extends ILocale { * ロールが付与されました */ "roleAssigned": string; + /** + * チャットルームへ招待されました + */ + "chatRoomInvitationReceived": string; /** * プッシュ通知の更新をしました */ @@ -9620,6 +10222,14 @@ export interface Locale extends ILocale { * ログインがありました */ "login": string; + /** + * アクセストークンが作成されました + */ + "createToken": string; + /** + * 心当たりがない場合は「{text}」を通じてアクセストークンを削除してください。 + */ + "createTokenDescription": ParameterizedString<"text">; "_types": { /** * すべて @@ -9669,6 +10279,10 @@ export interface Locale extends ILocale { * ロールが付与された */ "roleAssigned": string; + /** + * チャットルームへ招待された + */ + "chatRoomInvitationReceived": string; /** * 実績の獲得 */ @@ -9681,6 +10295,10 @@ export interface Locale extends ILocale { * ログイン */ "login": string; + /** + * アクセストークンの作成 + */ + "createToken": string; /** * 通知のテスト */ @@ -9738,6 +10356,18 @@ export interface Locale extends ILocale { * カラムの寄せ */ "columnAlign": string; + /** + * カラム間のマージン + */ + "columnGap": string; + /** + * デッキメニューの位置 + */ + "deckMenuPosition": string; + /** + * ナビゲーションバーの位置 + */ + "navbarPosition": string; /** * カラムを追加 */ @@ -9791,7 +10421,7 @@ export interface Locale extends ILocale { */ "introduction": string; /** - * 画面の右にある + を押して、いつでもカラムを追加できます。 + * カラムを追加するには、画面の + をクリックします。 */ "introduction2": string; /** @@ -9810,6 +10440,10 @@ export interface Locale extends ILocale { * 幅を自動調整 */ "flexible": string; + /** + * プロファイル情報のデバイス間同期を有効にする + */ + "enableSyncBetweenDevicesForProfiles": string; "_columns": { /** * メイン @@ -9851,6 +10485,10 @@ export interface Locale extends ILocale { * ロールタイムライン */ "roleTimeline": string; + /** + * チャット + */ + "chat": string; /** * Following */ @@ -10218,6 +10856,14 @@ export interface Locale extends ILocale { * ギャラリーの投稿を削除 */ "deleteGalleryPost": string; + /** + * チャットルームを削除 + */ + "deleteChatRoom": string; + /** + * プロキシアカウントの説明を更新 + */ + "updateProxyAccountDescription": string; /** * Approved */ @@ -10235,7 +10881,7 @@ export interface Locale extends ILocale { */ "setRemoteInstanceNSFW": string; /** - * Set remote instance as NSFW + * Unset remote instance as NSFW */ "unsetRemoteInstanceNSFW": string; /** @@ -10359,20 +11005,12 @@ export interface Locale extends ILocale { * このプラグインをインストールしますか? */ "title": string; - /** - * プラグイン情報 - */ - "metaTitle": string; }; "_theme": { /** * このテーマをインストールしますか? */ "title": string; - /** - * テーマ情報 - */ - "metaTitle": string; }; "_meta": { /** @@ -11106,11 +11744,11 @@ export interface Locale extends ILocale { }; "_followRequest": { /** - * 受け取った申請 + * Received */ "recieved": string; /** - * 送った申請 + * Sent */ "sent": string; }; @@ -11152,13 +11790,7 @@ export interface Locale extends ILocale { */ "title": string; /** - * このサーバーと通信することはできましたが、得られたデータが不正なものでした。 - */ - "description": string; - }; - "_responseInvalidIdHostNotMatch": { - /** - * 入力されたURIのドメインと最終的に得られたURIのドメインとが異なります。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。 + * このサーバーと通信することはできましたが、得られたデータが不正なものでした。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。 */ "description": string; }; @@ -11216,6 +11848,82 @@ export interface Locale extends ILocale { }; }; }; + "_bootErrors": { + /** + * 読み込みに失敗しました + */ + "title": string; + /** + * 少し待ってからリロードしてもまだ問題が解決されない場合、以下のError IDを添えてサーバー管理者に連絡してください。 + */ + "serverError": string; + /** + * 以下を行うと解決する可能性があります。 + */ + "solution": string; + /** + * ブラウザおよびOSを最新バージョンに更新する + */ + "solution1": string; + /** + * アドブロッカーを無効にする + */ + "solution2": string; + /** + * ブラウザのキャッシュをクリアする + */ + "solution3": string; + /** + * (Tor Browser) dom.webaudio.enabledをtrueに設定する + */ + "solution4": string; + /** + * その他のオプション + */ + "otherOption": string; + /** + * クライアント設定とキャッシュを削除 + */ + "otherOption1": string; + /** + * 簡易クライアントを起動 + */ + "otherOption2": string; + /** + * 修復ツールを起動 + */ + "otherOption3": string; + }; + "_search": { + /** + * 全て + */ + "searchScopeAll": string; + /** + * ローカル + */ + "searchScopeLocal": string; + /** + * サーバー指定 + */ + "searchScopeServer": string; + /** + * ユーザー指定 + */ + "searchScopeUser": string; + /** + * サーバーのホストを入力してください + */ + "pleaseEnterServerHost": string; + /** + * ユーザーを選択してください + */ + "pleaseSelectUser": string; + /** + * 例: misskey.example.com + */ + "serverHostPlaceholder": string; + }; /** * Approvals */ @@ -11577,6 +12285,10 @@ export interface Locale extends ILocale { * Cat friend :3 */ "oneko": string; + /** + * This will irreversibly delete this account. Proceed? + */ + "deleteThisAccountConfirm": string; /** * Enable Achievements */ @@ -11629,6 +12341,10 @@ export interface Locale extends ILocale { * Pending follow requests */ "pendingFollowRequests": string; + /** + * Are you sure you want to cancel your follow request? + */ + "undoFollowRequestConfirm": string; /** * Show quotes */ @@ -11776,6 +12492,14 @@ export interface Locale extends ILocale { * Displays content centered. */ "centerDescription": string; + /** + * Unix Time + */ + "unixtime": string; + /** + * Displays a timestamp in the viewer's current timezone. + */ + "unixtimeDescription": string; /** * Code (Inline) */ @@ -12255,6 +12979,18 @@ export interface Locale extends ILocale { * Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end. */ "mandatoryCWDescription": string; + /** + * Fetch linked note + */ + "fetchLinkedNote": string; + /** + * Add "Translate" to note action menu + */ + "showTranslationButtonInNoteFooter": string; + /** + * Failed to translate note. Please try again later or contact an administrator for assistance. + */ + "translationFailed": string; "_processErrors": { /** * Unable to process quote. This post may be missing context. @@ -12323,6 +13059,312 @@ export interface Locale extends ILocale { * I'm very sorry, but I'm not allowed to argue unless you've paid. */ "sudoInsults": string; + /** + * Authorized Fetch + */ + "authorizedFetchSection": string; + /** + * Allow unsigned ActivityPub requests: + */ + "authorizedFetchLabel": string; + /** + * This setting controls the behavior when a remote instance or user attempts to access your content without verifying their identity. If disabled, any remote user can access your profile and posts - even one who has been blocked or defederated. + */ + "authorizedFetchDescription": string; + "_authorizedFetchValue": { + /** + * Never + */ + "never": string; + /** + * Always + */ + "always": string; + /** + * Only for essential metadata + */ + "essential": string; + /** + * Use staff recommendation + */ + "staff": string; + }; + "_authorizedFetchValueDescription": { + /** + * Block all unsigned requests. Improves privacy and makes blocks more effective, but is not compatible with some very old or uncommon instance software. + */ + "never": string; + /** + * Allow all unsigned requests. Provides the greatest compatibility with other instances, but reduces privacy and weakens blocks. + */ + "always": string; + /** + * Allow some limited unsigned requests. Provides a hybrid between "Never" and "Always" by exposing only the minimum profile metadata that is required for federation with older software. + */ + "essential": string; + /** + * Use the default value of "{value}" recommended by the instance staff. + */ + "staff": ParameterizedString<"value">; + }; + /** + * The configuration property 'checkActivityPubGetSignature' has been deprecated and replaced with the new Authorized Fetch setting. Please remove it from your configuration file. + */ + "authorizedFetchLegacyWarning": string; + /** + * Deleted + */ + "deleted": string; + /** + * Enable the proxy account. + */ + "enableProxyAccount": string; + /** + * If disabled, then the proxy account will not be used. User lists will only include notes from local or followed users. + */ + "enableProxyAccountDescription": string; + "_confirmPollEdit": { + /** + * Are you sure you want to edit this poll + */ + "title": string; + /** + * Editing this poll will cause it to lose all previous votes + */ + "text": string; + }; + /** + * Test patterns + */ + "wordMuteTestLabel": string; + /** + * Enter some text here to test your word patterns. The matched words, if any, will be displayed below. + */ + "wordMuteTestDescription": string; + /** + * Test + */ + "wordMuteTestTest": string; + /** + * Matched words: {words} + */ + "wordMuteTestMatch": ParameterizedString<"words">; + /** + * No results yet, enter some text and click "Test" to check it. + */ + "wordMuteTestNoResults": string; + /** + * Text does not match any patterns. + */ + "wordMuteTestNoMatch": string; + /** + * All word mutes are *case-sensitive* and match on any substring, including part of a longer word or name. You can use regular expressions for more precise control. + */ + "wordMuteWarning": string; + /** + * Bubble timeline + */ + "bubbleTimeline": string; + /** + * Choose which instances should be displayed in the bubble. + */ + "bubbleTimelineDescription": string; + /** + * Note: the bubble timeline is hidden by default, and must be enabled via roles. + */ + "bubbleTimelineMustBeEnabled": string; + /** + * Users popular on the global network + */ + "popularUsersGlobal": string; + /** + * Users popular on {name} + */ + "popularUsersLocal": ParameterizedString<"name">; + /** + * Polls trending on {name} + */ + "pollsOnLocal": ParameterizedString<"name">; + /** + * Polls trending on the global network + */ + "pollsOnRemote": string; + /** + * Polls that have ended recently + */ + "pollsExpired": string; + /** + * Trending polls are disabled on this instance. + */ + "trendingPollsDisabled": string; + /** + * Please log in to view trending polls. + */ + "trendingPollsDisabledLogIn": string; + /** + * Silenced + */ + "silenced": string; + /** + * Total followers + */ + "totalFollowers": string; + /** + * Total following + */ + "totalFollowing": string; + /** + * Local followers + */ + "localFollowers": string; + /** + * Local following + */ + "localFollowing": string; + /** + * Remote followers + */ + "remoteFollowers": string; + /** + * Remote following + */ + "remoteFollowing": string; + /** + * Activity Pub + */ + "activityPub": string; + /** + * IP + */ + "ip": string; + /** + * The date is when IP address was first used. + */ + "ipTip": string; + /** + * Period + */ + "rolePeriod": string; + /** + * Assigned + */ + "roleAssigned": string; + /** + * automatic + */ + "roleAutomatic": string; + /** + * Translation timeout + */ + "translationTimeoutLabel": string; + /** + * Timeout in milliseconds for translation API requests. + */ + "translationTimeoutCaption": string; + /** + * Staff notes + */ + "staffNotes": string; + /** + * Icon of {name} + */ + "instanceIconAlt": ParameterizedString<"name">; + /** + * Attribution Domains + */ + "attributionDomains": string; + /** + * A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage: + */ + "attributionDomainsDescription": string; + /** + * Written by {user} + */ + "writtenBy": ParameterizedString<"user">; + /** + * Following (Pub) + */ + "followingPub": string; + /** + * Followers (Sub) + */ + "followersSub": string; + /** + * Well-known resources + */ + "wellKnownResources": string; + /** + * Last posted: {at} + */ + "lastPosted": ParameterizedString<"at">; + /** + * NSFW + */ + "nsfw": string; + /** + * Raw + */ + "raw": string; + /** + * CW + */ + "cw": string; + /** + * Media Silenced + */ + "mediaSilenced": string; + /** + * Bubble + */ + "bubble": string; + /** + * Verified + */ + "verified": string; + /** + * Not Verified + */ + "notVerified": string; + /** + * Hibernated + */ + "hibernated": string; + /** + * When replying to a post with a Content Warning, automatically use the same CW for the reply. + */ + "keepCwDescription": string; + /** + * Disabled (do not copy CWs) + */ + "keepCwDisabled": string; + /** + * Enabled (copy CWs verbatim) + */ + "keepCwEnabled": string; + /** + * Enabled (copy CW and prepend "RE:") + */ + "keepCwPrependRe": string; + /** + * Note controls + */ + "noteFooterLabel": string; + /** + * Packed user data in its raw form. Most of these fields are public and visible to all users. + */ + "rawUserDescription": string; + /** + * Extended user data in its raw form. These fields are private and can only be accessed by moderators. + */ + "rawInfoDescription": string; + /** + * ActivityPub user data in its raw form. These fields are public and accessible to other instances. + */ + "rawApDescription": string; + /** + * Signup Reason + */ + "signupReason": string; } declare const locales: { [lang: string]: Locale; diff --git a/locales/it-IT.yml b/locales/it-IT.yml index c233e3ab87..787c110189 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -126,7 +126,7 @@ pinnedNote: "Nota in primo piano" pinned: "Fissa sul profilo" you: "Tu" clickToShow: "Contenuto occultato, cliccare solo se si intende vedere" -sensitive: "Allegato esplicito" +sensitive: "Esplicito" add: "Aggiungi" reaction: "Reazioni" reactions: "Reazioni" @@ -228,7 +228,7 @@ jobQueue: "Coda di lavoro" cpuAndMemory: "CPU e Memoria" network: "Rete" disk: "Disco" -instanceInfo: "Informazioni sull'istanza" +instanceInfo: "Informazioni sul server" statistics: "Statistiche" clearQueue: "Svuota coda" clearQueueConfirmTitle: "Vuoi davvero svuotare la coda?" @@ -289,7 +289,6 @@ deleteAreYouSure: "Vuoi davvero eliminare \"{x}\"?" resetAreYouSure: "Ripristinare?" areYouSure: "Confermi?" saved: "Salvato" -messaging: "Messaggi" upload: "Carica" keepOriginalUploading: "Conservare l'immagine originale." keepOriginalUploadingDescription: "Conserva la versione originale quando si caricano le immagini. Se è disattivato, il browser genera l'immagine per la pubblicazione sul Web durante il caricamento." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "Il caricamento del file può richiedere tempo." explore: "Esplora" messageRead: "Visualizzato" noMoreHistory: "Non c'è più cronologia da visualizzare" -startMessaging: "Nuovo messaggio" +startChat: "Inizia a chattare" nUsersRead: "Letto da {n} persone" agreeTo: "Sono d'accordo con {0}" agree: "Accetto" @@ -383,7 +382,7 @@ disconnectService: "Disconnetti" enableLocalTimeline: "Abilita la timeline locale" enableGlobalTimeline: "Abilita la timeline federata" disablingTimelinesInfo: "Anche disabilitandole, gli Amministratori e i Moderatori potranno comunque accedervi." -registration: "Iscriviti" +registration: "Registrazione" invite: "Invita" driveCapacityPerLocalAccount: "Capienza del Drive per profilo locale" driveCapacityPerRemoteAccount: "Capienza del Drive per profilo remoto" @@ -425,6 +424,7 @@ antennaExcludeBots: "Escludere i Bot" antennaKeywordsDescription: "Sparando con uno spazio indichi la condizione E (and). Separando con un a capo, indichi la condizione O (or)." notifyAntenna: "Invia notifiche delle nuove note" withFileAntenna: "Solo note con file in allegato" +excludeNotesInSensitiveChannel: "Escludere le Note dai canali espliciti" enableServiceworker: "Abilita ServiceWorker" antennaUsersDescription: "Elenca un nome utente per riga" caseSensitive: "Sensibile alla distinzione tra maiuscole e minuscole" @@ -445,7 +445,7 @@ exploreFediverse: "Esplora il Fediverso" popularTags: "Hashtag popolari" userList: "Liste" about: "Informazioni" -aboutMisskey: "Informazioni di Misskey" +aboutMisskey: "A proposito di Misskey" administrator: "Amministratore" token: "Token" 2fa: "Autenticazione a due fattori" @@ -491,8 +491,6 @@ noteOf: "Note di {user}" quoteAttached: "Citazione allegata" quoteQuestion: "Vuoi aggiungere una citazione?" attachAsFileQuestion: "Il testo copiato eccede le dimensioni, vuoi allegarlo?" -noMessagesYet: "Ancora nessuna chat" -newMessageExists: "Hai ricevuto un nuovo messaggio" onlyOneFileCanBeAttached: "È possibile allegare al messaggio soltanto uno file" signinRequired: "Occorre avere un profilo registrato su questa istanza" signinOrContinueOnRemote: "Per continuare, devi accedere alla tua istanza o registrarti su questa e poi accedere" @@ -525,7 +523,7 @@ showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mou showReactionsCount: "Visualizza il numero di reazioni su una nota" noHistory: "Nessuna cronologia" signinHistory: "Storico degli accessi al profilo" -enableAdvancedMfm: "Attiva MFM avanzati" +enableAdvancedMfm: "Attivare i Misskey Flavoured Markdown (MFM) avanzati" enableAnimatedMfm: "Attiva MFM animati" doing: "In corso..." category: "Categoria" @@ -606,9 +604,9 @@ scratchpad: "ScratchPad" scratchpadDescription: "Lo Scratchpad offre un ambiente per esperimenti di AiScript. È possibile scrivere, eseguire e confermare i risultati dell'interazione del codice con Misskey." uiInspector: "UI Inspector" uiInspectorDescription: "Puoi visualizzare un elenco di elementi UI presenti in memoria. I componenti dell'interfaccia utente vengono generati dalle funzioni Ui:C:." -output: "Uscita" +output: "Output" script: "Script" -disablePagesScript: "Disabilita AiScript nelle pagine" +disablePagesScript: "Disabilitare AiScript nelle pagine" updateRemoteUser: "Aggiorna dati dal profilo remoto" unsetUserAvatar: "Rimozione foto profilo" unsetUserAvatarConfirm: "Vuoi davvero rimuovere la foto profilo?" @@ -666,7 +664,7 @@ generateAccessToken: "Genera token di accesso" permission: "Autorizzazioni " adminPermission: "Privilegi amministrativi" enableAll: "Abilita tutto" -disableAll: "Disabilita tutto" +disableAll: "Disabilitare tutto" tokenRequested: "Autorizza accesso al profilo" pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate qui." notificationType: "Tipo di notifiche" @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name} ha Notato a riguardo di \"{word}\"" makeActive: "Attiva" display: "Visualizza" copy: "Copia" +copiedToClipboard: "Copiato negli appunti" metrics: "Statistiche" overview: "Anteprima" logs: "Log" @@ -729,7 +728,7 @@ reporterOrigin: "Segnalazione da" send: "Inviare" openInNewTab: "Apri in una nuova scheda" openInSideView: "Apri in vista laterale" -defaultNavigationBehaviour: "Navigazione preimpostata" +defaultNavigationBehaviour: "Tipo di navigazione predefinita" editTheseSettingsMayBreakAccount: "Modificare queste impostazioni può danneggiare il profilo" instanceTicker: "Informazioni sull'istanza da cui vengono le note" waitingFor: "Aspettando {x}" @@ -766,9 +765,9 @@ driveUsage: "Utilizzazione del Drive" noCrawle: "Rifiuta l'indicizzazione dai robot." noCrawleDescription: "Richiedi che i motori di ricerca non indicizzino la tua pagina di profilo, le tue note, pagine, ecc." lockedAccountInfo: "A meno che non imposti la visibilità delle tue note su \"Solo ai follower\", le tue note sono visibili da tutti, anche se hai configurato l'account per confermare manualmente le richieste di follow." -alwaysMarkSensitive: "Segnare gli allegati come espliciti come opzione predefinita" +alwaysMarkSensitive: "Segnare automaticamente come espliciti gli allegati" loadRawImages: "Visualizza le intere immagini allegate invece delle miniature." -disableShowingAnimatedImages: "Disabilita le immagini animate" +disableShowingAnimatedImages: "Disabilitare le immagini animate" highlightSensitiveMedia: "Evidenzia i media espliciti" verificationEmailSent: "Una mail di verifica è stata inviata. Si prega di accedere al collegamento per compiere la verifica." notSet: "Non impostato" @@ -868,7 +867,7 @@ noBotProtectionWarning: "Non è stata impostata alcuna protezione dai Bot" configure: "Imposta" postToGallery: "Pubblicare nella galleria" postToHashtag: "Pubblica a questo hashtag" -gallery: "Galleria" +gallery: "Gallerie" recentPosts: "Pubblicazioni recenti" popularPosts: "Le più visualizzate" shareWithNote: "Condividere in nota" @@ -893,7 +892,7 @@ searchResult: "Risultati della Ricerca" hashtags: "Hashtag" troubleshooting: "Risoluzione problemi" useBlurEffect: "Utilizza effetto sfocatura" -learnMore: "Più dettagli" +learnMore: "Per saperne di più" misskeyUpdated: "Misskey è stato aggiornato!" whatIsNew: "Informazioni sull'aggiornamento" translate: "Traduci" @@ -901,7 +900,7 @@ translatedFrom: "Traduzione da {x}" accountDeletionInProgress: "È in corso l'eliminazione del profilo" usernameInfo: "Un nome per identificare univocamente il tuo profilo sull'istanza. Puoi utilizzare caratteri alfanumerici maiuscoli, minuscoli e il trattino basso (_). Non potrai cambiare nome utente in seguito." aiChanMode: "Modalità Ai" -devMode: "Modalità sviluppatori" +devMode: "Modalità sviluppo" keepCw: "Mostra i contenuti espliciti" pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" @@ -973,13 +972,14 @@ check: "Verifica" driveCapOverrideLabel: "Modificare la capienza del Drive per questo profilo" driveCapOverrideCaption: "Se viene specificato meno di 0, viene annullato." requireAdminForView: "Per visualizzarli, è necessario aver effettuato l'accesso con un profilo amministratore." -isSystemAccount: "Questi profili vengono creati e gestiti automaticamente dal sistema" +isSystemAccount: "Si tratta di un profilo creato e gestito automaticamente dal sistema." typeToConfirm: "Digita {x} per continuare" deleteAccount: "Eliminazione profilo" document: "Documentazione" numberOfPageCache: "Numero di pagine cache" numberOfPageCacheDescription: "Aumenta l'usabilità, ma aumenta anche il carico e l'utilizzo della memoria." logoutConfirm: "Vuoi davvero uscire da Misskey? " +logoutWillClearClientData: "All'uscita, la configurazione del client viene rimossa dal browser. Per ripristinarla quando si effettua nuovamente l'accesso, abilitare il backup automatico." lastActiveDate: "Data dell'ultimo utilizzo" statusbar: "Barra di stato" pleaseSelect: "Scegli un'opzione" @@ -1049,7 +1049,7 @@ permissionDeniedError: "Errore, attività non autorizzata" permissionDeniedErrorDescription: "Non si dispone dell'autorizzazione per eseguire questa operazione." preset: "Preimpostato" selectFromPresets: "Seleziona preimpostato" -achievements: "Obiettivi raggiunti" +achievements: "Conquiste" gotInvalidResponseError: "Risposta del server non valida" gotInvalidResponseErrorDescription: "Il server potrebbe essere irraggiungibile o in manutenzione. Riprova più tardi." thisPostMayBeAnnoying: "Questa nota potrebbe essere offensiva" @@ -1090,7 +1090,7 @@ notesSearchNotAvailable: "Non è possibile cercare tra le Note." license: "Licenza" unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?" myClips: "Le mie Clip" -drivecleaner: "Drive cleaner" +drivecleaner: "Pulizia del Drive" retryAllQueuesNow: "Ritenta di consumare tutte le code" retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?" retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente." @@ -1194,7 +1194,7 @@ renotes: "Rinota" loadReplies: "Leggi le risposte" loadConversation: "Leggi la conversazione" pinnedList: "Elenco in primo piano" -keepScreenOn: "Mantieni lo schermo acceso" +keepScreenOn: "Mantenere lo schermo acceso" verifiedLink: "Abbiamo confermato la validità di questo collegamento" notifyNotes: "Notifica nuove Note" unnotifyNotes: "Interrompi le notifiche di nuove Note" @@ -1236,7 +1236,7 @@ flip: "Inverti" showAvatarDecorations: "Mostra decorazione della foto profilo" releaseToRefresh: "Rilascia per aggiornare" refreshing: "Aggiornamento..." -pullDownToRefresh: "Trascina per aggiornare" +pullDownToRefresh: "Trascinare per aggiornare" disableStreamingTimeline: "Disabilitare gli aggiornamenti della TL in tempo reale" useGroupedNotifications: "Mostra le notifiche raggruppate" signupPendingError: "Si è verificato un problema durante la verifica del tuo indirizzo email. Potrebbe essere scaduto il collegamento temporaneo." @@ -1264,7 +1264,7 @@ backToTitle: "Torna al titolo" hemisphere: "Geolocalizzazione" withSensitive: "Mostra le Note con allegati espliciti" userSaysSomethingSensitive: "Note da {name} con allegati espliciti" -enableHorizontalSwipe: "Trascina per invertire i tab" +enableHorizontalSwipe: "Trascinare per invertire le colonne" loading: "Caricamento" surrender: "Annulla" gameRetry: "Riprova" @@ -1309,6 +1309,134 @@ availableRoles: "Ruoli disponibili" acknowledgeNotesAndEnable: "Attivare dopo averne compreso il comportamento." federationSpecified: "Questo server è federato solo con istanze specifiche del Fediverso. Puoi interagire solo con quelle scelte dall'amministrazione." federationDisabled: "Questo server ha la federazione disabilitata. Non puoi interagire con profili provenienti da altri server." +confirmOnReact: "Confermare le reazioni" +reactAreYouSure: "Vuoi davvero reagire con {emoji} ?" +markAsSensitiveConfirm: "Vuoi davvero indicare questo contenuto multimediale come esplicito?" +unmarkAsSensitiveConfirm: "Vuoi davvero indicare come non esplicito il contenuto multimediale?" +preferences: "Preferenze" +accessibility: "Accessibilità" +preferencesProfile: "Profilo preferenze" +copyPreferenceId: "Copia ID preferenze" +resetToDefaultValue: "Ripristina a predefinito" +overrideByAccount: "Sovrascrivere col profilo" +untitled: "Senza titolo" +noName: "Senza nome" +skip: "Salta" +restore: "Ripristina" +syncBetweenDevices: "Sincronizzazione tra i dispositivi" +preferenceSyncConflictTitle: "Sul server esiste già il valore impostato" +preferenceSyncConflictText: "Le impostazione sincronizzata salverà il valore sul server. Però, bada che esiste già un valore sul server. Quale vorresti sovrascrivere?" +preferenceSyncConflictChoiceServer: "Valore del server" +preferenceSyncConflictChoiceDevice: "Valore del dispositivo" +preferenceSyncConflictChoiceCancel: "Annulla la sincronizzazione" +paste: "Incolla" +emojiPalette: "Tavolozza emoji" +postForm: "Finestra di pubblicazione" +textCount: "Il numero di caratteri" +information: "Informazioni" +chat: "Chat" +migrateOldSettings: "Migrare le vecchie impostazioni" +migrateOldSettings_description: "Di solito, viene fatto automaticamente. Se per qualche motivo non fossero migrate con successo, è possibile avviare il processo di migrazione manualmente, sovrascrivendo le configurazioni attuali." +compress: "Comprimi" +right: "Destra" +bottom: "Sotto" +top: "Sopra" +embed: "Incorporare" +settingsMigrating: "Migrazione delle impostazioni. Attendere prego ... (Puoi anche migrare manualmente in un secondo momento, nel menu: Impostazioni → Altro → Migrazione delle impostazioni)" +readonly: "Sola lettura" +goToDeck: "Torna al Deck" +_chat: + noMessagesYet: "Ancora nessun messaggio" + newMessage: "Nuovo messaggio" + individualChat: "Chat individuale" + individualChat_description: "Puoi chattare con una persona specifica." + roomChat: "Stanza di chat" + roomChat_description: "Puoi chattare con più persone.\nInoltre, anche le persone che non consentono chat personalizzate possono chattare se gli altri accettano." + createRoom: "Crea stanza" + inviteUserToChat: "Invita a chattare altre persone" + yourRooms: "Le tue stanze" + joiningRooms: "Stanze a cui partecipi" + invitations: "Invita" + noInvitations: "Nessun invito" + history: "Cronologia" + noHistory: "Nessuna cronologia" + noRooms: "Nessuna stanza" + inviteUser: "Invita" + sentInvitations: "Inviti spediti" + join: "Entra" + ignore: "Ignora" + leave: "Esci" + members: "Membri" + searchMessages: "Cerca messaggi" + home: "Home" + send: "Inviare" + newline: "Nuova riga" + muteThisRoom: "Silenzia stanza" + deleteRoom: "Elimina stanza" + chatNotAvailableForThisAccountOrServer: "Questo server, o questo profilo ha disabilitato la chat." + chatIsReadOnlyForThisAccountOrServer: "Le chat, su questo server o su questo profilo, sono di sola lettura. Impossibile scrivere in chat o creare e partecipare a stanze." + chatNotAvailableInOtherAccount: "La chat non è disponibile nel profilo dell'altra persona." + cannotChatWithTheUser: "Impossibile chattare con questa persona" + cannotChatWithTheUser_description: "La chat potrebbe non essere disponibile, oppure l'altra persona potrebbe non esserlo." + chatWithThisUser: "Chatta con questa persona" + thisUserAllowsChatOnlyFromFollowers: "Questa persona permette di chattare soltanto i propri Follower." + thisUserAllowsChatOnlyFromFollowing: "Questa persona permette di chattare soltanto ai suoi Follow." + thisUserAllowsChatOnlyFromMutualFollowing: "Questa persona permette di chattare solo a relazioni reciproche." + thisUserNotAllowedChatAnyone: "Questa persona non permette di chattare a nessuno." + chatAllowedUsers: "Persone ammesse alla chat" + chatAllowedUsers_note: "Puoi chattare con le persone a cui hai già inviato un messaggio, indipendentemente da questa impostazione." + _chatAllowedUsers: + everyone: "Chiunque" + followers: "Solo i tuoi Follower" + following: "Solo i tuoi Follow" + mutual: "Solo relazioni reciproche" + none: "Nessuno" +_emojiPalette: + palettes: "Tavolozza" + enableSyncBetweenDevicesForPalettes: "Attiva la sincronizzazione tra dispositivi" + paletteForMain: "Tavolozza principale" + paletteForReaction: "Tavolozza per reazioni" +_settings: + driveBanner: "Permette di gestire e configurare il Drive, controllare il consumo di spazio e configurare il caricamento dei file." + pluginBanner: "Consentono di migliorare le funzionalità. Le estensioni si possono configurare e gestire singolarmente." + notificationsBanner: "Puoi impostare il tipo di notifiche da ricevere dal server e anche le notifiche push." + api: "API" + webhook: "Webhook" + serviceConnection: "Integrazione servizi" + serviceConnectionBanner: "Puoi gestire i codici di accesso e i Webhook per collegare App o servizi esterni." + accountData: "Dati del profilo" + accountDataBanner: "Puoi gestire i dati del tuo profilo, esportando e importando." + muteAndBlockBanner: "Puoi configurare la visibiltà dei contenuti e limitare le attività provenienti da profili specifici." + accessibilityBanner: "Puoi personalizzare e migliorare la lettura sul tuo dispositivo in modo che sia più chiaro e reattivo." + privacyBanner: "Puoi configurare la privacy del tuo profilo, come la visibilità delle Note, la visibilità del profilo nelle ricerche e l'approvazione delle relazioni tra profili." + securityBanner: "Puoi gestire la sicurezza del tuo account, la password, i modi di accesso, la generazione di codici OTP per accesso multi fattore (MFA/2FA) e la passkey." + preferencesBanner: "Puoi personalizzare il comportamento del tuo dispositivo." + appearanceBanner: "Puoi personalizzare l'aspetto nel dispositivo, in base alle tue preferenze." + soundsBanner: "Puoi personalizzare i suoni emessi dagli eventi sul tuo dispositivo." + timelineAndNote: "Note e Timeline" + makeEveryTextElementsSelectable: "Imposta ogni elemento come selezionabile" + makeEveryTextElementsSelectable_description: "Potrebbe ridurre l'usabilità in alcune situazioni." + useStickyIcons: "Fissa le icone durante lo scorrimento" + showNavbarSubButtons: "Mostra i pulsanti secondari nella barra di navigazione" + ifOn: "Quando attivato" + ifOff: "Quando disattivato" + enableSyncThemesBetweenDevices: "Sincronizzare il tema tra i dispositivi" + _chat: + showSenderName: "Mostra il nome del mittente" + sendOnEnter: "Invio spedisce" +_preferencesProfile: + profileName: "Nome del profilo" + profileNameDescription: "Impostare il nome che indentifica questo dispositivo." + profileNameDescription2: "Es: \"PC principale\" o \"Cellulare\"" +_preferencesBackup: + autoBackup: "Backup automatico" + restoreFromBackup: "Ripristinare da backup" + noBackupsFoundTitle: "Nessun backup trovato" + noBackupsFoundDescription: "Impossibile trovare un backup creato automaticamente. Se se hai salvato il file di backup manualmente, puoi importarlo e ripristinarlo." + selectBackupToRestore: "Seleziona un backup da ripristinare" + youNeedToNameYourProfileToEnableAutoBackup: "Per abilitare i backup automatici, è necessario indicare il nome del profilo." + autoPreferencesBackupIsNotEnabledForThisDevice: "Su questo dispositivo non è stato attivato il backup automatico delle preferenze." + backupFound: "Esiste il Backup delle preferenze" _accountSettings: requireSigninToViewContents: "Per vedere il contenuto, è necessaria l'iscrizione" requireSigninToViewContentsDescription1: "Richiedere l'iscrizione per visualizzare tutte le Note e gli altri contenuti che hai creato. Probabilmente l'effetto è impedire la raccolta di informazioni da parte dei bot crawler." @@ -1319,6 +1447,7 @@ _accountSettings: makeNotesHiddenBefore: "Nascondi le Note pubblicate in precedenza" makeNotesHiddenBeforeDescription: "Mentre questa funzione è abilitata, le Note antecedenti al momento impostato, saranno visibili soltanto a te (private). Disabilitandola nuovamente, verrà ripristinata anche la visibilità pubblica della Nota." mayNotEffectForFederatedNotes: "Le Note già federate su server remoti potrebbero non essere modificate." + mayNotEffectSomeSituations: "Queste restrizioni sono semplificate. In alcuni casi, potrebbero anche non avvenire. Ad esempio visionando un server remoto o durante la moderazione." notesHavePassedSpecifiedPeriod: "Note antecedenti al periodo specificato" notesOlderThanSpecifiedDateAndTime: "Note antecedenti al momento specificato" _abuseUserReport: @@ -1445,9 +1574,9 @@ _initialTutorial: description: "Queste sono solamente alcune delle funzionalità principali di Misskey. Per ulteriori informazioni, {link}." _timelineDescription: home: "Nella Timeline Home, la tua cronologia principale, puoi vedere le Note provenienti dai profili che segui (Following)." - local: "La Timeline Locale, è una cronologia di Note pubblicate da tutti i profili iscritti su questo server." - social: "La Timeline Sociale, unisce in ordine cronologico l'elenco di Note presenti nella Timeline Home e quella Locale." - global: "La Timeline Federata ti consente di vedere le Note pubblicate dai profili di tutti gli altri server federati a questo." + local: "La Timeline Locale è un flusso di Note pubblicate dai profili iscritti a questo server." + social: "La Timeline Sociale elenca, in ordine cronologico, il flusso di Note nella Timeline Home e Locale." + global: "Nella Timeline Federata trovi il flusso di Note provenienti da profili iscritti ad altri server, federati a questo." _serverRules: description: "In Europa è necessario mostrare l'informativa sul trattamento dei dati personali, prima della registrazione al servizio." _serverSettings: @@ -1471,8 +1600,8 @@ _serverSettings: _accountMigration: moveFrom: "Migra un altro profilo dentro a questo" moveFromSub: "Crea un alias verso un altro profilo remoto" - moveFromLabel: "Profilo da cui migrare #{n}" - moveFromDescription: "Se desideri spostare i Follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@istanza.it" + moveFromLabel: "Profilo da cui migrare n. {n}" + moveFromDescription: "Se desideri spostare i Follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@vecchia.istanza.it" moveTo: "Migrare questo profilo verso un un altro" moveToLabel: "Profilo verso cui migrare" moveCannotBeUndone: "La migrazione è irreversibile, non può essere interrotta o annullata." @@ -1548,13 +1677,13 @@ _achievements: title: "Principiante III" description: "Hai totalizzato 15 accessi!" _login30: - title: "Misskist I" + title: "Missalcolista I" description: "Hai totalizzato 30 accessi!" _login60: - title: "Misskeist II" + title: "Missalcolista II" description: "Hai totalizzato 60 accessi!" _login100: - title: "Misskeist III" + title: "Missalcolista III" description: "Hai totalizzato 100 accessi!" flavor: "Violent Misskeist" _login200: @@ -1640,10 +1769,10 @@ _achievements: description: "Hai superato i 1.000 profili Follower" _collectAchievements30: title: "Collezionista di successi" - description: "Hai raggiunto 30 obiettivi" + description: "Hai raggiunto 30 conquiste" _viewAchievements3min: title: "Mi piacciono i risultati" - description: "Guarda la tua collezione di obiettivi per almeno 3 minuti" + description: "Ammira la tua collezione di conquiste per almeno 3 minuti" _iLoveMisskey: title: "I LOVE Misskey" description: "Pubblica «I ♥ #Misskey»" @@ -1764,6 +1893,8 @@ _role: descriptionOfIsExplorable: "Selezionandolo, la timeline del ruolo diventerà accessibile pubblicamente. Tranne se il ruolo non è pubblico." displayOrder: "Ordine di visualizzazione" descriptionOfDisplayOrder: "I valori più alti vengono visualizzati per primi" + preserveAssignmentOnMoveAccount: "Mantenere l'assegnazione alla migrazione del profilo" + preserveAssignmentOnMoveAccount_description: "Attivando, il ruolo verrà portato sul profilo destinatario, durante la migrazione." canEditMembersByModerator: "Anche i Moderatori assegnano profili a questo ruolo" descriptionOfCanEditMembersByModerator: "Se disattivo, potranno farlo solamente gli Amministratori." priority: "Priorità" @@ -1804,6 +1935,7 @@ _role: canImportFollowing: "Può importare Following" canImportMuting: "Può importare Silenziati" canImportUserLists: "Può importare liste di Profili" + chatAvailability: "Chat consentita" _condition: roleAssignedTo: "Assegnato a ruoli manualmente" isLocal: "Profilo locale" @@ -1908,7 +2040,7 @@ _registry: domain: "Dominio" createKey: "Crea chiave" _aboutMisskey: - about: "Misskey è un software libero e open source, sviluppato da syuilo dal 2014." + about: "Misskey è software libero, open source, sviluppato da Syuilo fin dal lontano 2014." contributors: "Principali sostenitori" allContributors: "Tutti i sostenitori" source: "Codice sorgente" @@ -1967,6 +2099,7 @@ _theme: installed: "{name} è installato" installedThemes: "Temi installati" builtinThemes: "Temi integrati" + instanceTheme: "Tema dell'istanza" alreadyInstalled: "Questo tema è già installato" invalid: "Il formato tema non è valido" make: "Crea un tema" @@ -1999,14 +2132,13 @@ _theme: header: "Intestazione" navBg: "Sfondo della barra laterale" navFg: "Testo della barra laterale" - navHoverFg: "Testo della barra laterale (al passaggio del mouse)" navActive: "Testo della barra laterale (attivo)" navIndicator: "Indicatore di barra laterale" link: "Link" hashtag: "Hashtag" mention: "Menzioni" mentionMe: "Menzioni (di me)" - renote: "Rinota" + renote: "Renota" modalBg: "Sfondo modale." divider: "Interruzione di linea" scrollbarHandle: "Maniglie della barra di scorrimento" @@ -2022,17 +2154,15 @@ _theme: buttonHoverBg: "Sfondo del pulsante (sorvolato)" inputBorder: "Inquadra casella di testo" driveFolderBg: "Sfondo della cartella di disco" - wallpaperOverlay: "Sovrapposizione dello sfondo" badge: "Distintivo" messageBg: "Sfondo della chat" - accentDarken: "Temi (scuri)" - accentLighten: "Temi (luminosi)" fgHighlighted: "Testo in evidenza." _sfx: note: "Nota" noteMy: "Mia nota" notification: "Notifiche" reaction: "Quando seleziono una reazione" + chatMessage: "Messaggio di chat" _soundSettings: driveFile: "Suoni del Drive" driveFileWarn: "Seleziona file dal dispositivo" @@ -2179,6 +2309,8 @@ _permissions: "read:clip-favorite": "Vedere Clip preferite" "read:federation": "Vedere la federazione" "write:report-abuse": "Inviare segnalazioni" + "write:chat": "Gestire la chat" + "read:chat": "Visualizzare le chat" _auth: shareAccessTitle: "Permessi dell'applicazione" shareAccess: "Vuoi autorizzare {name} ad accedere al tuo profilo?" @@ -2235,8 +2367,9 @@ _widgets: userList: "Elenco utenti" _userList: chooseList: "Seleziona una lista" - clicker: "Cliccaggio" + clicker: "Cliccheria" birthdayFollowings: "Compleanni del giorno" + chat: "Chat" _cw: hide: "Nascondere" show: "Continua la lettura..." @@ -2298,7 +2431,7 @@ _profile: metadataContent: "Contenuto" changeAvatar: "Modifica immagine profilo" changeBanner: "Cambia intestazione" - verifiedLinkDescription: "Puoi verificare il tuo profilo mostrando una icona. Devi inserire la URL alla pagina che contiene un link al tuo profilo." + verifiedLinkDescription: "Puoi verificare il tuo profilo mostrando una icona. Devi inserire la URL alla pagina che contiene un link al tuo profilo.\nPer verificare il profilo tramite la spunta di conferma, devi inserire la url alla pagina che contiene un link al tuo profilo Misskey. Deve avere attributo rel='me'." avatarDecorationMax: "Puoi aggiungere fino a {max} decorazioni." followedMessage: "Messaggio, quando qualcuno ti segue" followedMessageDescription: "Puoi impostare un breve messaggio da mostrare agli altri profili quando ti seguono." @@ -2427,6 +2560,7 @@ _notification: newNote: "Nuove Note" unreadAntennaNote: "Antenna {name}" roleAssigned: "Ruolo assegnato" + chatRoomInvitationReceived: "Invito in una stanza di chat" emptyPushNotificationMessage: "Le notifiche push sono state aggiornate." achievementEarned: "Obiettivo raggiunto" testNotification: "Provare la notifica" @@ -2440,6 +2574,8 @@ _notification: flushNotification: "Azzera le notifiche" exportOfXCompleted: "Abbiamo completato l'esportazione di {x}" login: "Autenticazione avvenuta" + createToken: "È stato creato un token di accesso" + createTokenDescription: "In caso contrario, eliminare il token di accesso tramite ({text})." _types: all: "Tutto" note: "Nuove Note" @@ -2453,9 +2589,11 @@ _notification: receiveFollowRequest: "Richieste di follow in arrivo" followRequestAccepted: "Richieste di follow accettate" roleAssigned: "Ruolo concesso" + chatRoomInvitationReceived: "Invito in una stanza di chat" achievementEarned: "Risultato raggiunto" exportCompleted: "Esportazione completata" login: "Accessi" + createToken: "Creare un token di accesso" test: "Notifiche di test" app: "Notifiche da applicazioni" _actions: @@ -2464,7 +2602,10 @@ _notification: renote: "Rinota" _deck: alwaysShowMainColumn: "Mostra sempre la colonna principale" - columnAlign: "Allineare colonne" + columnAlign: "Allineamento delle colonne" + columnGap: "Spessore del margine tra colonne" + deckMenuPosition: "Posizione del menu Deck" + navbarPosition: "Posizione barra di navigazione" addColumn: "Aggiungi colonna" newNoteNotificationSettings: "Preferenze per le notifiche di nuove Note" configureColumn: "Impostazioni colonna" @@ -2483,6 +2624,7 @@ _deck: useSimpleUiForNonRootPages: "Visualizza sotto pagine con interfaccia web semplice" usedAsMinWidthWhenFlexible: "Se \"larghezza flessibile\" è abilitato, questa diventa la larghezza minima" flexible: "Larghezza flessibile" + enableSyncBetweenDevicesForProfiles: "Abilita la sincronizzazione delle informazioni profilo tra dispositivi" _columns: main: "Principale" widgets: "Riquadri" @@ -2490,10 +2632,11 @@ _deck: tl: "Timeline" antenna: "Antenne" list: "Liste" - channel: "Canale" + channel: "Canali" mentions: "Menzioni" direct: "Note Dirette" roleTimeline: "Timeline Ruolo" + chat: "Chat" _dialog: charactersExceeded: "Hai superato il limite di {max} caratteri! ({current})" charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({current})" @@ -2501,8 +2644,8 @@ _disabledTimeline: title: "Timeline disabilitata" description: "Il ruolo in cui sei non ti permette di leggere questa timeline" _drivecleaner: - orderBySizeDesc: "Dal più grande al più piccolo" - orderByCreatedAtAsc: "Dal più vecchio al più recente" + orderBySizeDesc: "Dal file più grosso al più piccolo" + orderByCreatedAtAsc: "Dal file più vecchio al più recente" _webhookSettings: createWebhook: "Creazione Webhook" modifyWebhook: "Modifica Webhook" @@ -2590,6 +2733,8 @@ _moderationLogTypes: deletePage: "Pagina eliminata" deleteFlash: "Play eliminato" deleteGalleryPost: "Eliminazione pubblicazione nella Galleria" + deleteChatRoom: "Elimina chat" + updateProxyAccountDescription: "Aggiornata la descrizione del profilo proxy" _fileViewer: title: "Dettagli del file" type: "Tipo di file" @@ -2603,10 +2748,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Prima di installare, assicurati che la fonte sia affidabile." _plugin: title: "Vuoi davvero installare questo componente aggiuntivo?" - metaTitle: "Informazioni sul componente aggiuntivo" _theme: title: "Vuoi davvero installare questa variazione grafica?" - metaTitle: "Informazioni sulla variazione grafica" _meta: base: "Combinazione base di colori" _vendorInfo: @@ -2780,7 +2923,7 @@ _customEmojisManager: directoryToCategoryLabel: "Inseriscile in una cartella omonima alla categoria" directoryToCategoryCaption: "Crea il campo categoria in base alla cartella." emojiInputAreaCaption: "Seleziona l'emoji da registrare utilizzando uno dei metodi." - emojiInputAreaList1: "Trascina una immagine o una cartella in quest'area" + emojiInputAreaList1: "Trascinare una immagine o una cartella in quest'area" emojiInputAreaList2: "Clicca per scegliere file dal tuo dispositivo" emojiInputAreaList3: "Clicca per selezionare dal Drive" confirmRegisterEmojisDescription: "Registrazione delle emoji elencate come nuove emoji personalizzate. Vuoi davvero procedere? (Per evitare sovraccarichi, puoi registrare al massimo {count} emoji per volta)" @@ -2807,8 +2950,8 @@ _selfXssPrevention: description2: "Se non sai esattamente cosa stai facendo, %c smetti subito e chiudi questa finestra." description3: "Per favore, controlla questo collegamento per avere maggiori dettagli. {link}" _followRequest: - recieved: "Ricezione richiesta di Follow" - sent: "Richiesta di Follow, inviata" + recieved: "Richieste in ingresso" + sent: "Richieste in uscita" _remoteLookupErrors: _federationNotAllowed: title: "Server irraggiungibile" @@ -2822,8 +2965,6 @@ _remoteLookupErrors: _responseInvalid: title: "Risposta non valida" description: "La comunicazione col server è andata a buon fine, ma abbiamo ricevuto dati non validi." - _responseInvalidIdHostNotMatch: - description: "L'indirizzo immesso non coincide con la URL finale. Interrogando i server per un contenuto remoto, assicurarsi di utilizzare la URL finale e non quella di un server intermedio." _noSuchObject: title: "Non trovato" description: "La risorsa richiesta non è stata trovata. Verificare nuovamente la URL." @@ -2840,3 +2981,23 @@ _captcha: _unknown: title: "Errore CAPTCHA" text: "Si è verificato un errore imprevisto." +_bootErrors: + title: "Caricamento non riuscito" + serverError: "Dopo una breve attesa, e dopo aver ricaricato la pagina, se il problema persiste, contatta l'amministrazione comunicando il seguente ID di errore." + solution: "Di seguito, alcune probabili soluzioni al problema." + solution1: "Aggiornare browser e il sistema operativo all'ultima versione" + solution2: "Disattivare gli adblocker" + solution3: "Cancellare la cache del browser" + solution4: "(Per chi utilizza il Browser Tor) Impostare dom.webaudio.enabled = vero" + otherOption: "Altre opzioni" + otherOption1: "Nelle impostazioni, cancellare le impostazioni del client e svuotare la cache" + otherOption2: "Avviare il client predefinito" + otherOption3: "Avviare lo strumento di riparazione" +_search: + searchScopeAll: "Tutte" + searchScopeLocal: "Locale" + searchScopeServer: "Specifiche del server" + searchScopeUser: "Profilo specifico" + pleaseEnterServerHost: "Inserire il nome host" + pleaseSelectUser: "Per favore, seleziona un profilo" + serverHostPlaceholder: "Es: misskey.example.com" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 13d8aec9b8..edb7914110 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "「{x}」を削除しますか?" resetAreYouSure: "リセットしますか?" areYouSure: "よろしいですか?" saved: "保存しました" -messaging: "チャット" upload: "アップロード" keepOriginalUploading: "オリジナル画像を保持" keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。" @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がか explore: "みつける" messageRead: "既読" noMoreHistory: "これより過去の履歴はありません" -startMessaging: "チャットを開始" +startChat: "チャットを始める" nUsersRead: "{n}人が読みました" agreeTo: "{0}に同意" agree: "同意する" @@ -425,6 +424,7 @@ antennaExcludeBots: "Botアカウントを除外" antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" notifyAntenna: "新しいノートを通知する" withFileAntenna: "ファイルが添付されたノートのみ" +excludeNotesInSensitiveChannel: "センシティブなチャンネルのノートを除外" enableServiceworker: "ブラウザへのプッシュ通知を有効にする" antennaUsersDescription: "ユーザー名を改行で区切って指定します" caseSensitive: "大文字小文字を区別する" @@ -491,8 +491,6 @@ noteOf: "{user}のノート" quoteAttached: "引用付き" quoteQuestion: "引用として添付しますか?" attachAsFileQuestion: "クリップボードのテキストが長いです。テキストファイルとして添付しますか?" -noMessagesYet: "まだチャットはありません" -newMessageExists: "新しいメッセージがあります" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" signinRequired: "続行する前に、登録またはログインが必要です" signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります" @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name}が「{word}」について何かを言いまし makeActive: "アクティブにする" display: "表示" copy: "コピー" +copiedToClipboard: "クリップボードにコピーされました" metrics: "メトリクス" overview: "概要" logs: "ログ" @@ -980,6 +979,7 @@ document: "ドキュメント" numberOfPageCache: "ページキャッシュ数" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" logoutConfirm: "ログアウトしますか?" +logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。" lastActiveDate: "最終利用日時" statusbar: "ステータスバー" pleaseSelect: "選択してください" @@ -1238,7 +1238,7 @@ releaseToRefresh: "離してリロード" refreshing: "リロード中" pullDownToRefresh: "引っ張ってリロード" disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする" -useGroupedNotifications: "通知をグルーピングして表示する" +useGroupedNotifications: "通知をグルーピング" signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" doReaction: "リアクションする" @@ -1309,6 +1309,142 @@ availableRoles: "利用可能なロール" acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。" federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。" federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。" +confirmOnReact: "リアクションする際に確認する" +reactAreYouSure: "\" {emoji} \" をリアクションしますか?" +markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?" +unmarkAsSensitiveConfirm: "このメディアのセンシティブ指定を解除しますか?" +preferences: "環境設定" +accessibility: "アクセシビリティ" +preferencesProfile: "設定のプロファイル" +copyPreferenceId: "設定IDをコピー" +resetToDefaultValue: "初期値に戻す" +overrideByAccount: "アカウントで上書き" +untitled: "無題" +noName: "名前はありません" +skip: "スキップ" +restore: "復元" +syncBetweenDevices: "デバイス間で同期" +preferenceSyncConflictTitle: "サーバーに設定値が存在します" +preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか?" +preferenceSyncConflictChoiceServer: "サーバーの設定値" +preferenceSyncConflictChoiceDevice: "デバイスの設定値" +preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル" +paste: "ペースト" +emojiPalette: "絵文字パレット" +postForm: "投稿フォーム" +textCount: "文字数" +information: "情報" +chat: "チャット" +migrateOldSettings: "旧設定情報を移行" +migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。" +compress: "圧縮" +right: "右" +bottom: "下" +top: "上" +embed: "埋め込み" +settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)" +readonly: "読み取り専用" +goToDeck: "デッキへ戻る" +federationJobs: "連合ジョブ" +driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。
\nノートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。
\nファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。
\nフォルダを作って整理することもできます。" + +_chat: + noMessagesYet: "まだメッセージはありません" + newMessage: "新しいメッセージ" + individualChat: "個人チャット" + individualChat_description: "特定ユーザーとの一対一のチャットができます。" + roomChat: "ルームチャット" + roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。" + createRoom: "ルームを作成" + inviteUserToChat: "ユーザーを招待してチャットを始めましょう" + yourRooms: "作成したルーム" + joiningRooms: "参加中のルーム" + invitations: "招待" + noInvitations: "招待はありません" + history: "履歴" + noHistory: "履歴はありません" + noRooms: "ルームはありません" + inviteUser: "ユーザーを招待" + sentInvitations: "送信した招待" + join: "参加" + ignore: "無視" + leave: "ルームから退出" + members: "メンバー" + searchMessages: "メッセージを検索" + home: "ホーム" + send: "送信" + newline: "改行" + muteThisRoom: "このルームをミュート" + deleteRoom: "ルームを削除" + chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。" + chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。" + chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" + cannotChatWithTheUser: "このユーザーとのチャットを開始できません" + cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" + chatWithThisUser: "チャットする" + thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。" + thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。" + thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみチャットを受け付けています。" + thisUserNotAllowedChatAnyone: "このユーザーは誰からもチャットを受け付けていません。" + chatAllowedUsers: "チャットを許可する相手" + chatAllowedUsers_note: "自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。" + _chatAllowedUsers: + everyone: "誰でも" + followers: "自分のフォロワーのみ" + following: "自分がフォローしているユーザーのみ" + mutual: "相互フォローのユーザーのみ" + none: "誰も許可しない" + +_emojiPalette: + palettes: "パレット" + enableSyncBetweenDevicesForPalettes: "パレットのデバイス間同期を有効にする" + paletteForMain: "メインで使用するパレット" + paletteForReaction: "リアクションで使用するパレット" + +_settings: + driveBanner: "ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。" + pluginBanner: "プラグインを利用するとクライアントの機能を拡張することができます。プラグインのインストール、個別の設定と管理が行えます。" + notificationsBanner: "サーバーからの受信する通知の種類と範囲や、プッシュ通知の設定が行えます。" + api: "API" + webhook: "Webhook" + serviceConnection: "サービス連携" + serviceConnectionBanner: "外部のアプリ・サービスと連携するためのアクセストークンやWebhookの管理と設定が行えます。" + accountData: "アカウントのデータ" + accountDataBanner: "アカウントデータのアーカイブをエクスポート/インポートして管理できます。" + muteAndBlockBanner: "非表示にするコンテンツの設定や、特定のユーザーからのアクションを制限する設定と管理を行えます。" + accessibilityBanner: "クライアントの視覚や動作に関するパーソナライズを行い、より最適に使用できるように設定できます。" + privacyBanner: "コンテンツの公開範囲、見つけやすさ、フォローの承認制などアカウントのプライバシーに関する設定を行えます。" + securityBanner: "パスワード、ログイン方法、認証アプリ、パスキーなどアカウントのセキュリティに関する設定を行えます。" + preferencesBanner: "好みに応じた、クライアントの全体的な動作の設定が行えます。" + appearanceBanner: "好みに応じた、クライアントの見た目・表示方法に関する設定が行えます。" + soundsBanner: "クライアントで再生するサウンドの設定が行えます。" + timelineAndNote: "タイムラインとノート" + makeEveryTextElementsSelectable: "全てのテキスト要素を選択可能にする" + makeEveryTextElementsSelectable_description: "有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。" + useStickyIcons: "アイコンをスクロールに追従させる" + showNavbarSubButtons: "ナビゲーションバーに副ボタンを表示" + ifOn: "オンのとき" + ifOff: "オフのとき" + enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期" + + _chat: + showSenderName: "送信者の名前を表示" + sendOnEnter: "Enterで送信" + +_preferencesProfile: + profileName: "プロファイル名" + profileNameDescription: "このデバイスを識別する名前を設定してください。" + profileNameDescription2: "例: 「メインPC」、「スマホ」など" + +_preferencesBackup: + autoBackup: "自動バックアップ" + restoreFromBackup: "バックアップから復元" + noBackupsFoundTitle: "バックアップが見つかりませんでした" + noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。" + selectBackupToRestore: "復元するバックアップを選択してください" + youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。" + autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。" + backupFound: "設定のバックアップが見つかりました" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" @@ -1320,6 +1456,7 @@ _accountSettings: makeNotesHiddenBefore: "過去のノートを非公開化する" makeNotesHiddenBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートが自分のみ表示可能(非公開化)になります。無効に戻すと、ノートの公開状態も元に戻ります。" mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばない場合があります。" + mayNotEffectSomeSituations: "これらの制限は簡易的なものです。リモートサーバーでの閲覧やモデレーション時など、一部のシチュエーションでは適用されない場合があります。" notesHavePassedSpecifiedPeriod: "指定した時間を経過しているノート" notesOlderThanSpecifiedDateAndTime: "指定した日時より前のノート" @@ -1777,6 +1914,8 @@ _role: descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。" displayOrder: "表示順" descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" + preserveAssignmentOnMoveAccount: "アサイン状態を移行先アカウントにも引き継ぐ" + preserveAssignmentOnMoveAccount_description: "オンにすると、このロールが付与されたアカウントが移行された際に、移行先アカウントにもこのロールが引き継がれるようになります。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" priority: "優先度" @@ -1796,6 +1935,7 @@ _role: canManageCustomEmojis: "カスタム絵文字の管理" canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" + maxFileSize: "アップロード可能な最大ファイルサイズ" alwaysMarkNsfw: "ファイルにNSFWを常に付与" canUpdateBioMedia: "アイコンとバナーの更新を許可" pinMax: "ノートのピン留めの最大数" @@ -1817,6 +1957,7 @@ _role: canImportFollowing: "フォローのインポートを許可" canImportMuting: "ミュートのインポートを許可" canImportUserLists: "リストのインポートを許可" + chatAvailability: "チャットを許可" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -2001,6 +2142,7 @@ _theme: installed: "{name}をインストールしました" installedThemes: "インストールされたテーマ" builtinThemes: "標準のテーマ" + instanceTheme: "サーバーのテーマ" alreadyInstalled: "そのテーマは既にインストールされています" invalid: "テーマの形式が間違っています" make: "テーマを作る" @@ -2032,16 +2174,15 @@ _theme: panel: "パネル" shadow: "影" header: "ヘッダー" - navBg: "サイドバーの背景" - navFg: "サイドバーの文字" - navHoverFg: "サイドバー文字(ホバー)" - navActive: "サイドバー文字(アクティブ)" - navIndicator: "サイドバーのインジケーター" + navBg: "ナビゲーションバーの背景" + navFg: "ナビゲーションバーの文字" + navActive: "ナビゲーションバー文字(アクティブ)" + navIndicator: "ナビゲーションバーのインジケーター" link: "リンク" hashtag: "ハッシュタグ" mention: "メンション" mentionMe: "あなた宛てメンション" - renote: "Renote" + renote: "リノート" modalBg: "モーダルの背景" divider: "分割線" scrollbarHandle: "スクロールバーの取っ手" @@ -2057,11 +2198,8 @@ _theme: buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" driveFolderBg: "ドライブフォルダーの背景" - wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" messageBg: "チャットの背景" - accentDarken: "アクセント (暗め)" - accentLighten: "アクセント (明るめ)" fgHighlighted: "強調された文字" _sfx: @@ -2069,6 +2207,7 @@ _sfx: noteMy: "ノート(自分)" notification: "通知" reaction: "リアクション選択時" + chatMessage: "チャットのメッセージ" _soundSettings: driveFile: "ドライブの音声を使用" @@ -2221,6 +2360,8 @@ _permissions: "read:clip-favorite": "クリップのいいねを見る" "read:federation": "連合に関する情報を取得する" "write:report-abuse": "違反を報告する" + "write:chat": "チャットを操作する" + "read:chat": "チャットを閲覧する" _auth: shareAccessTitle: "アプリへのアクセス許可" @@ -2283,6 +2424,7 @@ _widgets: chooseList: "リストを選択" clicker: "クリッカー" birthdayFollowings: "今日誕生日のユーザー" + chat: "チャット" _cw: hide: "隠す" @@ -2487,6 +2629,7 @@ _notification: newNote: "新しい投稿" unreadAntennaNote: "アンテナ {name}" roleAssigned: "ロールが付与されました" + chatRoomInvitationReceived: "チャットルームへ招待されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" testNotification: "通知テスト" @@ -2500,6 +2643,8 @@ _notification: flushNotification: "通知の履歴をリセットする" exportOfXCompleted: "{x}のエクスポートが完了しました" login: "ログインがありました" + createToken: "アクセストークンが作成されました" + createTokenDescription: "心当たりがない場合は「{text}」を通じてアクセストークンを削除してください。" _types: all: "すべて" @@ -2514,9 +2659,11 @@ _notification: receiveFollowRequest: "フォロー申請を受け取った" followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" + chatRoomInvitationReceived: "チャットルームへ招待された" achievementEarned: "実績の獲得" exportCompleted: "エクスポートが完了した" login: "ログイン" + createToken: "アクセストークンの作成" test: "通知のテスト" app: "連携アプリからの通知" @@ -2528,6 +2675,9 @@ _notification: _deck: alwaysShowMainColumn: "常にメインカラムを表示" columnAlign: "カラムの寄せ" + columnGap: "カラム間のマージン" + deckMenuPosition: "デッキメニューの位置" + navbarPosition: "ナビゲーションバーの位置" addColumn: "カラムを追加" newNoteNotificationSettings: "新着ノート通知の設定" configureColumn: "カラムの設定" @@ -2541,11 +2691,12 @@ _deck: newProfile: "新規プロファイル" deleteProfile: "プロファイルを削除" introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!" - introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。" + introduction2: "カラムを追加するには、画面の + をクリックします。" widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選択してウィジェットを追加してください" useSimpleUiForNonRootPages: "非ルートページは簡易UIで表示" usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となります" flexible: "幅を自動調整" + enableSyncBetweenDevicesForProfiles: "プロファイル情報のデバイス間同期を有効にする" _columns: main: "メイン" @@ -2558,6 +2709,7 @@ _deck: mentions: "あなた宛て" direct: "ダイレクト" roleTimeline: "ロールタイムライン" + chat: "チャット" _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" @@ -2660,6 +2812,8 @@ _moderationLogTypes: deletePage: "ページを削除" deleteFlash: "Playを削除" deleteGalleryPost: "ギャラリーの投稿を削除" + deleteChatRoom: "チャットルームを削除" + updateProxyAccountDescription: "プロキシアカウントの説明を更新" _fileViewer: title: "ファイルの詳細" @@ -2675,10 +2829,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "配布元が信頼できるかを確認した上でインストールしてください。" _plugin: title: "このプラグインをインストールしますか?" - metaTitle: "プラグイン情報" _theme: title: "このテーマをインストールしますか?" - metaTitle: "テーマ情報" _meta: base: "基本のカラースキーム" _vendorInfo: @@ -2907,9 +3059,7 @@ _remoteLookupErrors: description: "このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。" _responseInvalid: title: "レスポンスが不正です" - description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。" - _responseInvalidIdHostNotMatch: - description: "入力されたURIのドメインと最終的に得られたURIのドメインとが異なります。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。" + description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。" _noSuchObject: title: "見つかりません" description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。" @@ -2927,3 +3077,25 @@ _captcha: _unknown: title: "CAPTCHAエラー" text: "想定外のエラーが発生しました。" + +_bootErrors: + title: "読み込みに失敗しました" + serverError: "少し待ってからリロードしてもまだ問題が解決されない場合、以下のError IDを添えてサーバー管理者に連絡してください。" + solution: "以下を行うと解決する可能性があります。" + solution1: "ブラウザおよびOSを最新バージョンに更新する" + solution2: "アドブロッカーを無効にする" + solution3: "ブラウザのキャッシュをクリアする" + solution4: "(Tor Browser) dom.webaudio.enabledをtrueに設定する" + otherOption: "その他のオプション" + otherOption1: "クライアント設定とキャッシュを削除" + otherOption2: "簡易クライアントを起動" + otherOption3: "修復ツールを起動" + +_search: + searchScopeAll: "全て" + searchScopeLocal: "ローカル" + searchScopeServer: "サーバー指定" + searchScopeUser: "ユーザー指定" + pleaseEnterServerHost: "サーバーのホストを入力してください" + pleaseSelectUser: "ユーザーを選択してください" + serverHostPlaceholder: "例: misskey.example.com" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 66560f524b..378eaf2ad5 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -5,6 +5,7 @@ introMisskey: "ようお越し!Misskeyは、オープンソースの分散型 poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつなんやで。" monthAndDay: "{month}月 {day}日" search: "探す" +reset: "リセット" notifications: "通知" username: "ユーザー名" password: "パスワード" @@ -48,6 +49,7 @@ pin: "ピン留めしとく" unpin: "ピン留めやめる" copyContent: "内容をコピー" copyLink: "リンクをコピー" +copyRemoteLink: "リモートのリンクをコピーするで?" copyLinkRenote: "リノートのリンクをコピーするで?" delete: "ほかす" deleteAndEdit: "ほかして直す" @@ -287,7 +289,6 @@ deleteAreYouSure: "「{x}」はほかしてええか?" resetAreYouSure: "リセットしてええん?" areYouSure: "いいん?" saved: "保存したで!" -messaging: "チャット" upload: "アップロード" keepOriginalUploading: "オリジナル画像のまんま" keepOriginalUploadingDescription: "画像を上げるときにオリジナル版のまんまにするで。オフにしたら、上げたときにブラウザでWeb公開用の画像を生成するで。 " @@ -300,7 +301,6 @@ uploadFromUrlMayTakeTime: "アップロード終わるんにちょい時間か explore: "みつける" messageRead: "もう読んだ" noMoreHistory: "これより昔のんはあらへんで" -startMessaging: "チャットやるで" nUsersRead: "{n}人が読んでもうた" agreeTo: "{0}に同意したで" agree: "せやな" @@ -489,8 +489,6 @@ noteOf: "{user}はんのノート" quoteAttached: "引用付いとるで" quoteQuestion: "引用として添付してもええか?" attachAsFileQuestion: "クリップボードのテキストが長すぎるからテキストファイルとして添付してもええか?" -noMessagesYet: "まだチャットはあらへんで" -newMessageExists: "新しいメッセージがきたで" onlyOneFileCanBeAttached: "ごめんな、メッセージに添付できるファイルはひとつだけなんよ。" signinRequired: "ログインしてくれへん?" signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があるで" @@ -684,11 +682,15 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecureInfo: "STARTTLS使っとる時はオフにしてや。" testEmail: "配信テスト" wordMute: "ワードミュート" +wordMuteDescription: "指定した語句が入ってるノートを最小化するで。最小化されたノートをクリックしたら、表示できるようになるで。" hardWordMute: "ハードワードミュート" +showMutedWord: "ミュートされたワードを表示するで" +hardWordMuteDescription: "指定した語句が入ってるノートを隠すで。ワードミュートとちゃうて、ノートは完全に表示されんようになるで。" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが出てきたで:" instanceMute: "サーバーミュート" userSaysSomething: "{name}が何か言うとるわ" +userSaysSomethingAbout: "{name}が「{word}」についてなんか言うてたで" makeActive: "使うで" display: "表示" copy: "コピー" @@ -1301,6 +1303,20 @@ lockdown: "ロックダウン" pleaseSelectAccount: "アカウント選んでや" availableRoles: "使えるロール" acknowledgeNotesAndEnable: "注意事項をわかった上でオンにする。" +federationSpecified: "このサーバーはホワイトリスト連合で運用されてるで。管理者が指定したサーバー以外とはやり取りできひんで。" +federationDisabled: "このサーバーは連合が無効化されてるで。他のサーバーのユーザーとやり取りすることはできひんで。" +confirmOnReact: "ツッコむときに確認とる" +reactAreYouSure: "\" {emoji} \" でツッコむ?" +postForm: "投稿フォーム" +information: "情報" +_chat: + invitations: "来てや" + noHistory: "履歴はないわ。" + members: "メンバーはん" + home: "ホーム" + send: "送信" +_settings: + webhook: "Webhook" _accountSettings: requireSigninToViewContents: "ログインしてもらってからコンテンツ見てもらう" requireSigninToViewContentsDescription1: "あなたが作成した全部のノートとかのコンテンツを見れるようにするのにログインがいるようにするで。クローラーにいろいろ収集されるんを防げるかもしれん。" @@ -1991,7 +2007,6 @@ _theme: header: "ヘッダー" navBg: "サイドバーの背景" navFg: "サイドバーの文字" - navHoverFg: "サイドバー文字(ホバー)" navActive: "サイドバー文字(アクティブ)" navIndicator: "サイドバーのインジケーター" link: "リンク" @@ -2014,11 +2029,8 @@ _theme: buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" driveFolderBg: "ドライブフォルダーの背景" - wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" messageBg: "チャットの背景" - accentDarken: "アクセント (暗め)" - accentLighten: "アクセント (明るめ)" fgHighlighted: "強調されとる文字" _sfx: note: "ノート" @@ -2171,6 +2183,7 @@ _permissions: "read:clip-favorite": "クリップのいいね見る" "read:federation": "連合の情報取得" "write:report-abuse": "違反報告" + "write:chat": "チャットを操作するで" _auth: shareAccessTitle: "アプリへのアクセス許してやったらどうや" shareAccess: "「{name}」がアカウントにアクセスすることを許可してええか?" @@ -2432,6 +2445,8 @@ _notification: flushNotification: "通知の履歴をリセットする" exportOfXCompleted: "{x}のエクスポートが終わったわ" login: "ログインしとったで" + createToken: "アクセストークンが作成されたで" + createTokenDescription: "心当たりないんやったら「{text}」でアクセストークンを削除してやって。" _types: all: "すべて" note: "あんたらの新規投稿" @@ -2595,10 +2610,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "配ってるとこが信頼できるか確認した上でインストールしてな。" _plugin: title: "このプラグイン、インストールする?" - metaTitle: "プラグイン情報" _theme: title: "このテーマインストールする?" - metaTitle: "テーマ情報" _meta: base: "" _vendorInfo: @@ -2718,6 +2731,66 @@ _contextMenu: app: "アプリ" appWithShift: "Shiftキーでアプリ" native: "ブラウザのUI" +_gridComponent: + _error: + requiredValue: "この値は必須項目やで" + columnTypeNotSupport: "正規表現によるバリデーションはtype:textのカラムだけサポートしてるで" + patternNotMatch: "この値は{pattern}のパターンに一致しいひんで" + notUnique: "この値は一意でなあかんで" +_roleSelectDialog: + notSelected: "選択されとらんで" +_customEmojisManager: + _gridCommon: + copySelectionRows: "選択行をコピーするで" + copySelectionRanges: "選択範囲をコピーするで" + deleteSelectionRows: "選択行を削除するで" + deleteSelectionRanges: "選択範囲の値をクリアするで" + searchSettings: "検索設定" + searchSettingCaption: "検索条件を詳しく設定するで。" + searchLimit: "表示件数" + sortOrder: "並び順" + registrationLogs: "登録ログ" + registrationLogsCaption: "絵文字更新・削除時のログが表示されるで。更新・削除操作をしたり、ページを遷移・リロードしたら消えるから気ぃつけてな。" + alertEmojisRegisterFailedDescription: "絵文字の更新・削除に失敗したで。詳細は登録ログを確認してな。" + _logs: + showSuccessLogSwitch: "成功ログを表示するで" + failureLogNothing: "失敗ログはあらへん。" + logNothing: "失敗ログはあらへん。" + _remote: + selectionRowDetail: "選択行の詳細やで" + importSelectionRows: "選択行をインポートするで" + importSelectionRangesRows: "選択範囲の行をインポートするで" + importEmojisButton: "チェックされた絵文字をインポートするで" + confirmImportEmojisTitle: "絵文字のインポートするで" + confirmImportEmojisDescription: "リモートから受信した{count}個の絵文字をインポートするで。絵文字のライセンスには十分気ぃつけてな。実行してもええか?" + _local: + tabTitleList: "登録済み絵文字一覧" + tabTitleRegister: "絵文字の登録" + _list: + emojisNothing: "登録された絵文字はないで。" + markAsDeleteTargetRows: "選択行を削除対象にするで" + markAsDeleteTargetRanges: "選択範囲の行を削除対象にするで" + alertUpdateEmojisNothingDescription: "変更された絵文字はないで。" + alertDeleteEmojisNothingDescription: "削除対象の絵文字はないで。" + confirmMovePage: "ページを移動してもええんか?" + confirmChangeView: "表示を変更してもええんか?" + confirmUpdateEmojisDescription: "{count}個の絵文字を更新するで。実行してもええか?" + confirmDeleteEmojisDescription: "チェックがつけられた{count}個の絵文字を削除するで。ほんまにええか?" + confirmResetDescription: "今までやった変更が全部リセットされるで。" + confirmMovePageDesciption: "このページの絵文字に変更が加えられてるで。\n保存せずページを移動してまうと、このページで加えた変更が全てパーになるで。" + dialogSelectRoleTitle: "絵文字に設定されたロールで検索" + _register: + uploadSettingTitle: "アップロード設定" + uploadSettingDescription: "この画面で絵文字アップロードするときの動きを設定できるで。" + directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する" + directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。" + emojiInputAreaCaption: "どれかの方法で登録する絵文字を選択して。" + emojiInputAreaList1: "この枠に画像ファイルかディレクトリをドラッグ&ドロップ" + emojiInputAreaList2: "このリンクをクリックしてPCから選択する" + emojiInputAreaList3: "このリンクをクリックしてドライブから選択する" + confirmRegisterEmojisDescription: "リストに表示されてる絵文字を新たなカスタム絵文字として登録するで。ほんまにええか? (サーバーがしんどくなるから、一回で登録できる絵文字は{count}件までやで)" + confirmClearEmojisDescription: "編集内容をほかして、リストに表示されている絵文字をクリアするで。ほんまにええか?" + confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードするで。ほんまにええか?" _embedCodeGen: title: "埋め込みコードをカスタム" header: "ヘッダー出す" @@ -2754,8 +2827,35 @@ _remoteLookupErrors: _responseInvalid: title: "レスポンスがおかしいで" description: "このサーバーと通信することはできたけど、もらったデータがおかしかったで。" - _responseInvalidIdHostNotMatch: - description: "入力されたURIのドメインと最終的に得られたURIのドメインとが違うで。第三者のサーバーを介してリモートのコンテンツを照会してるんやったら、発信元のサーバーで取得できるURIを使って照会し直して。" _noSuchObject: title: "見つからへんね" description: "求められたリソースが見つからんかったで。URIをもっかい確かめてや。" +_captcha: + verify: "CAPTCHAしばいたって" + testSiteKeyMessage: "サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できるで。\n詳細は下記ページを確認してな。" + _error: + _requestFailed: + title: "CAPTCHAのリクエストに失敗してもうた" + text: "しばらく後で実行するか、設定をもっかい確認してや。" + _verificationFailed: + title: "CAPTCHAのリクエストに失敗してもうた" + text: "設定がほんまに合ってるかもっかい確認してや。" + _unknown: + title: "CAPTCHAエラー" + text: "思いもせんかったエラーが起きたわ。" +_bootErrors: + title: "読み込みに失敗したで" + serverError: "少し待ってからリロードしてもまだ問題が解決されんのやったら、以下のError IDを添えてサーバー管理者に連絡して。" + solution: "以下のことやったら解決するかもやで。" + solution1: "ブラウザとかOSを最新バージョンに更新する" + solution2: "アドブロッカーを無効にする" + solution3: "ブラウザのキャッシュをクリアする" + solution4: "(Tor Browser) dom.webaudio.enabledをtrueに設定する" + otherOption: "ほかのオプション" + otherOption1: "クライアント設定とキャッシュをほかす" + otherOption2: "簡易クライアントを起動" + otherOption3: "修復ツールを起動" +_search: + searchScopeAll: "みんな" + searchScopeLocal: "ローカル" + searchScopeUser: "ユーザー指定" diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index 4b9650b636..fb21b47fac 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -263,7 +263,6 @@ deleteAreYouSure: "‘{x}’(얼)럴 뭉캡니꺼?" resetAreYouSure: "아시로 데돌립니꺼?" areYouSure: "갠찮십니꺼?" saved: "저장햇십니다" -messaging: "대화" upload: "올리기" keepOriginalUploading: "온본 두기" keepOriginalUploadingDescription: "이미지럴 올릴 때 온본얼 고대로 둡니다. 꺼모 올릴 때 브라우저서 웹 공개 이미지럴 맨겁니다." @@ -276,7 +275,6 @@ uploadFromUrlMayTakeTime: "올리기가 껕날라먼 시간이 쪼매 걸릴 깁 explore: "살펴보기" messageRead: "이럿어예" noMoreHistory: "요카마 옛날 기록이 어ᇝ십니다" -startMessaging: "대화하기" nUsersRead: "{n}멩이 이럿십니다" agreeTo: "{0}에 동이하기" agree: "동이합니다" @@ -457,8 +455,6 @@ retype: "다시 서기" noteOf: "{user}님으 노트" quoteAttached: "따옴" quoteQuestion: "따와가 작성하겠십니까?" -noMessagesYet: "아직 대화가 없십니다" -newMessageExists: "새 메시지가 있십니다" onlyOneFileCanBeAttached: "메시지엔 파일 하나까제밖에 몬 넣십니다" invitations: "초대하기" invitationCode: "초대장" @@ -655,6 +651,12 @@ replies: "답하기" renotes: "리노트" attach: "옇기" surrender: "아이예" +information: "정보" +_chat: + invitations: "초대하기" + noHistory: "기록이 없십니다" + members: "구성원" + home: "덜머리" _delivery: stop: "고만 보내예" _type: @@ -745,6 +747,7 @@ _theme: description: "설멩" keys: mention: "멘션" + renote: "리노트" _sfx: note: "새 노트" notification: "알림" @@ -843,3 +846,6 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "몬 찾앗십니다" +_search: + searchScopeAll: "말캉" + searchScopeUser: "사용자 지정" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 36b818c117..57c66fad33 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -64,8 +64,8 @@ copyNoteId: "노트 ID 복사" copyFileId: "파일 ID 복사" copyFolderId: "폴더 ID 복사" copyProfileUrl: "프로필 URL 복사" -searchUser: "사용자 검색" -searchThisUsersNotes: "사용자의 노트 검색" +searchUser: "유저 검색" +searchThisUsersNotes: "유저의 노트를 검색" reply: "답글" loadMore: "더 보기" showMore: "더 보기" @@ -267,7 +267,7 @@ publishing: "배포 중" notResponding: "응답 없음" instanceFollowing: "서버의 팔로잉" instanceFollowers: "서버의 팔로워" -instanceUsers: "서버의 사용자" +instanceUsers: "서버의 유저" changePassword: "비밀번호 변경" security: "보안" retypedNotMatch: "입력이 일치하지 않습니다." @@ -289,7 +289,6 @@ deleteAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?" resetAreYouSure: "초기화 하시겠습니까?" areYouSure: "계속 진행하시겠습니까?" saved: "저장했습니다" -messaging: "대화" upload: "업로드" keepOriginalUploading: "원본 이미지를 유지" keepOriginalUploadingDescription: "이미지를 업로드할 때에 원본을 그대로 유지합니다. 비활성화하면 업로드할 때 브라우저에서 웹 공개용 이미지를 생성합니다." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 explore: "둘러보기" messageRead: "읽음" noMoreHistory: "이것보다 과거의 기록이 없습니다" -startMessaging: "대화 시작하기" +startChat: "채팅을 시작하기" nUsersRead: "{n}명이 읽음" agreeTo: "{0}에 동의" agree: "동의합니다" @@ -386,12 +385,12 @@ disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리 registration: "등록" invite: "초대" driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" -driveCapacityPerRemoteAccount: "원격 사용자별 드라이브 용량" +driveCapacityPerRemoteAccount: "리모트 유저별 드라이브 용량" inMb: "메가바이트 단위" bannerUrl: "배너 이미지 URL" backgroundImageUrl: "배경 이미지 URL" basicInfo: "기본 정보" -pinnedUsers: "고정한 사용자" +pinnedUsers: "고정한 유저" pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다." pinnedPages: "고정한 페이지" pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적습니다." @@ -419,12 +418,13 @@ antennas: "안테나" manageAntennas: "안테나 관리" name: "이름" antennaSource: "받을 소스" -antennaKeywords: "받을 검색어" -antennaExcludeKeywords: "제외할 검색어" +antennaKeywords: "받을 키워드" +antennaExcludeKeywords: "제외할 키워드" antennaExcludeBots: "봇 계정 제외" antennaKeywordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다" notifyAntenna: "새로운 노트를 알림" withFileAntenna: "파일이 첨부된 노트만" +excludeNotesInSensitiveChannel: "민감한 채널의 노트 제외" enableServiceworker: "ServiceWorker 사용" antennaUsersDescription: "유저명을 한 줄에 한 명씩 적습니다" caseSensitive: "대소문자를 구분" @@ -436,11 +436,11 @@ silence: "사일런스" silenceConfirm: "이 계정을 사일런스로 설정하시겠습니까?" unsilence: "사일런스 해제" unsilenceConfirm: "이 계정의 사일런스를 해제하시겠습니까?" -popularUsers: "인기 사용자" -recentlyUpdatedUsers: "최근에 활동한 사용자" -recentlyRegisteredUsers: "최근에 가입한 사용자" -recentlyDiscoveredUsers: "최근에 발견한 사용자" -exploreUsersCount: "{count}명의 사용자가 있습니다" +popularUsers: "인기 유저" +recentlyUpdatedUsers: "최근에 활동한 유저" +recentlyRegisteredUsers: "최근에 가입한 유저" +recentlyDiscoveredUsers: "최근에 발견한 유저" +exploreUsersCount: "{count}명의 유저가 있습니다" exploreFediverse: "연합우주를 탐색" popularTags: "인기 태그" userList: "리스트" @@ -489,10 +489,8 @@ next: "다음" retype: "다시 입력" noteOf: "{user}의 노트" quoteAttached: "인용함" -quoteQuestion: "인용해서 작성하시겠습니까?" +quoteQuestion: "인용해서 첨부하시겠습니까?" attachAsFileQuestion: "붙여넣으려는 글이 너무 깁니다. 텍스트 파일로 첨부하시겠습니까?" -noMessagesYet: "아직 대화가 없습니다" -newMessageExists: "새 메시지가 있습니다" onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하나까지입니다" signinRequired: "진행하기 전에 로그인을 해 주세요" signinOrContinueOnRemote: "계속하려면 사용하는 서버로 이동하거나 이 서버에 로그인해야 합니다." @@ -510,7 +508,7 @@ strongPassword: "강한 비밀번호" passwordMatched: "일치합니다" passwordNotMatched: "일치하지 않습니다" signinWith: "{x}로 로그인" -signinFailed: "로그인할 수 없습니다. 사용자 이름과 비밀번호를 확인해 주십시오." +signinFailed: "로그인할 수 없습니다. 유저 이름과 비밀번호를 확인해 주십시오." or: "혹은" language: "언어" uiLanguage: "UI 표시 언어" @@ -522,7 +520,7 @@ style: "스타일" drawer: "서랍" popup: "팝업" showNoteActionsOnlyHover: "마우스가 올라간 때에만 노트 동작 버튼을 표시하기" -showReactionsCount: "노트의 반응 수를 표시하기" +showReactionsCount: "노트의 리액션 수를 표시하기" noHistory: "기록이 없습니다" signinHistory: "로그인 기록" enableAdvancedMfm: "고급 MFM을 활성화" @@ -573,8 +571,8 @@ objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급합니다. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해야 할 수 있습니다." serverLogs: "서버 로그" deleteAll: "모두 삭제" -showFixedPostForm: "타임라인 상단에 글 작성란을 표시" -showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란을 표시" +showFixedPostForm: "타임라인 상단에 글 입력란을 표시" +showFixedPostFormInChannel: "채널 타임라인 상단에 글 입력란을 표시" withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 나오게 하기" newNoteRecived: "새 노트가 있습니다" sounds: "소리" @@ -609,7 +607,7 @@ uiInspectorDescription: "메모리에 있는 UI 컴포넌트의 인스턴트 목 output: "출력" script: "스크립트" disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" -updateRemoteUser: "원격 사용자 정보 갱신" +updateRemoteUser: "리모트 유저 정보 갱신" unsetUserAvatar: "아바타 제거" unsetUserAvatarConfirm: "아바타를 제거할까요?" unsetUserBanner: "배너 제거" @@ -618,7 +616,7 @@ deleteAllFiles: "모든 파일 삭제" deleteAllFilesConfirm: "모든 파일을 삭제하시겠습니까?" removeAllFollowing: "모든 팔로잉 해제" removeAllFollowingDescription: "{host} 서버의 모든 팔로잉을 해제합니다. 해당 서버가 더 이상 존재하지 않는 경우 등에 실행해 주세요." -userSuspended: "이 사용자는 정지되었습니다." +userSuspended: "이 유저는 정지되었습니다." userSilenced: "이 계정은 사일런스된 상태입니다." yourAccountSuspendedTitle: "계정이 정지되었습니다" yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반하거나, 기타 다른 이유로 인해 정지되었습니다. 자세한 사항은 관리자에게 문의해 주십시오. 계정을 새로 생성하지 마십시오." @@ -679,7 +677,7 @@ emailAddress: "메일 주소" smtpConfig: "SMTP 서버 설정" smtpHost: "호스트" smtpPort: "포트" -smtpUser: "사용자 이름" +smtpUser: "유저 이름" smtpPass: "비밀번호" emptyToDisableSmtpAuth: "SMTP 인증을 사용하지 않으려면 공란으로 비워둡니다." smtpSecure: "SMTP 연결에 Implicit SSL/TTS 사용" @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name}님이 \"{word}\"를 언급했습니다." makeActive: "활성화" display: "보기" copy: "복사" +copiedToClipboard: "클립보드에 복사되었습니다." metrics: "통계" overview: "요약" logs: "로그" @@ -721,7 +720,7 @@ abuseReports: "신고" reportAbuse: "신고" reportAbuseRenote: "리노트 신고하기" reportAbuseOf: "{name} 신고하기" -fillAbuseReportDescription: "신고하려는 이유를 자세히 알려주세요. 특정 게시물을 신고할 때에는 게시물의 URL도 포함해 주세요." +fillAbuseReportDescription: "신고 사유를 자세히 기재해 주세요. 대상 노트나 페이지 등이 있는 경우에는 해당 URL도 기재해 주세요." abuseReported: "신고를 보냈습니다. 신고해 주셔서 감사합니다." reporter: "신고자" reporteeOrigin: "피신고자" @@ -755,8 +754,8 @@ repliedCount: "받은 답글 수" renotedCount: "받은 리노트 수" followingCount: "팔로우 수" followersCount: "팔로워 수" -sentReactionsCount: "반응 수" -receivedReactionsCount: "받은 반응 수" +sentReactionsCount: "리액션 수" +receivedReactionsCount: "받은 리액션 수" pollVotesCount: "투표 수" pollVotedCount: "받은 투표 수" yes: "예" @@ -764,7 +763,7 @@ no: "아니오" driveFilesCount: "드라이브에 있는 파일 수" driveUsage: "드라이브 사용량" noCrawle: "검색엔진의 인덱싱 거부" -noCrawleDescription: "검색엔진에 사용자 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 합니다." +noCrawleDescription: "검색엔진에 유저 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 합니다." lockedAccountInfo: "팔로우를 승인으로 승인받더라도 노트의 공개 범위를 '팔로워'로 하지 않는 한 누구나 당신의 노트를 볼 수 있습니다." alwaysMarkSensitive: "미디어를 항상 열람 주의로 설정" loadRawImages: "첨부한 이미지의 썸네일을 원본화질로 표시" @@ -796,7 +795,7 @@ needReloadToApply: "변경 사항은 새로고침하면 적용됩니다." showTitlebar: "타이틀 바를 표시하기" clearCache: "캐시 비우기" onlineUsersCount: "{n}명이 접속 중" -nUsers: "{n} 사용자" +nUsers: "{n} 유저" nNotes: "{n} 노트" sendErrorReports: "오류 보고서 보내기" sendErrorReportsDescription: "이 설정을 활성화하면, 문제가 발생했을 때 오류에 대한 상세 정보를 Misskey에 보내어 더 나은 소프트웨어를 만드는 데에 도움을 줄 수 있습니다." @@ -826,7 +825,7 @@ editCode: "코드 수정" apply: "적용" receiveAnnouncementFromInstance: "이 서버의 알림을 이메일로 수신할게요" emailNotification: "메일 알림" -publish: "게시" +publish: "공개" inChannelSearch: "채널에서 검색" useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기" typingUsers: "{users}님이 입력 중" @@ -842,7 +841,7 @@ addDescription: "설명 추가" userPagePinTip: "각 노트의 메뉴에서 「프로필에 고정」을 선택하는 것으로, 여기에 노트를 표시해 둘 수 있어요." notSpecifiedMentionWarning: "수신자가 선택되지 않은 멘션이 있어요" info: "정보" -userInfo: "사용자 정보" +userInfo: "유저 정보" unknown: "알 수 없음" onlineStatus: "온라인 상태" hideOnlineStatus: "온라인 상태 숨기기" @@ -858,7 +857,7 @@ switchAccount: "계정 바꾸기" enabled: "활성화" disabled: "비활성화" quickAction: "빠른 동작" -user: "사용자" +user: "유저" administration: "관리" accounts: "계정" switch: "전환" @@ -869,8 +868,8 @@ configure: "설정하기" postToGallery: "갤러리에 업로드" postToHashtag: "이 해시태그에 게시" gallery: "갤러리" -recentPosts: "최근 포스트" -popularPosts: "인기 포스트" +recentPosts: "최근 게시물" +popularPosts: "인기 게시물" shareWithNote: "노트로 공유" ads: "광고" expiration: "기한" @@ -899,7 +898,7 @@ whatIsNew: "패치 정보 보기" translate: "번역" translatedFrom: "{x}에서 번역" accountDeletionInProgress: "계정 삭제 작업을 진행하고 있습니다" -usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. 사용자명은 나중에 변경할 수 없습니다." +usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. 유저명은 나중에 변경할 수 없습니다." aiChanMode: "아이 모드" devMode: "개발자 모드" keepCw: "CW 유지하기" @@ -980,6 +979,7 @@ document: "문서" numberOfPageCache: "페이지 캐시 수" numberOfPageCacheDescription: "숫자가 클 수록 편리성이 높아지지만, 시스템 자원과 메모리를 더 많이 사용합니다." logoutConfirm: "로그아웃 하시겠습니까?" +logoutWillClearClientData: "로그아웃하면 클라이언트의 설정 데이터가 브라우저에서 지워지게 됩니다. 다시 로그인할 때 설정 데이터를 복원할 수 있도록 하려면 설정 자동 백업을 활성화하세요." lastActiveDate: "마지막 이용" statusbar: "상태바" pleaseSelect: "선택해 주세요" @@ -1033,7 +1033,7 @@ correspondingSourceIsAvailable: "소스 코드는 {anchor}에서 받아보실 roles: "역할" role: "역할" noRole: "역할이 없습니다" -normalUser: "일반 사용자" +normalUser: "일반 유저" undefined: "정의되지 않음" assign: "할당" unassign: "할당 취소" @@ -1057,7 +1057,7 @@ thisPostMayBeAnnoyingHome: "홈에 게시" thisPostMayBeAnnoyingCancel: "그만두기" thisPostMayBeAnnoyingIgnore: "이대로 게시" collapseRenotes: "이미 본 리노트를 간략화하기" -collapseRenotesDescription: "반응이나 리노트를 한 노트를 접어서 표시합니다." +collapseRenotesDescription: "리액션이나 리노트를 한 노트를 접어서 표시합니다." internalServerError: "내부 서버 오류" internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했습니다." copyErrorInfo: "오류 정보 복사" @@ -1081,8 +1081,8 @@ resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?" sensitiveWords: "민감한 단어" sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다." sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." -prohibitedWords: "금지 워드" -prohibitedWordsDescription: "설정된 워드가 포함되는 노트를 작성하려고 하면, 에러가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." +prohibitedWords: "금지 단어" +prohibitedWordsDescription: "설정된 단어가 포함되는 노트를 게시하려고 하면, 오류가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." hiddenTags: "숨긴 해시태그" hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다." @@ -1107,7 +1107,7 @@ audio: "소리" audioFiles: "소리" dataSaver: "데이터 절약 모드" accountMigration: "계정 이동" -accountMoved: "이 사용자는 다음 계정으로 이사했습니다:" +accountMoved: "이 유저는 다음 계정으로 이사했습니다:" accountMovedShort: "이사한 계정입니다" operationForbidden: "사용할 수 없습니다" forceShowAds: "광고를 항상 표시" @@ -1128,8 +1128,8 @@ serverRules: "서버 규칙" pleaseConfirmBelowBeforeSignup: "이 서버에 가입하기 전에 아래 사항을 확인하여 주십시오." pleaseAgreeAllToContinue: "계속하시려면 모든 항목에 동의하십시오." continue: "계속" -preservedUsernames: "예약한 사용자 이름" -preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." +preservedUsernames: "예약한 유저명" +preservedUsernamesDescription: "예약할 유저명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 유저명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." createNoteFromTheFile: "이 파일로 노트를 작성" archive: "아카이브" archived: "아카이브 됨" @@ -1143,7 +1143,7 @@ youFollowing: "팔로잉" preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부" preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구합니다. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아닙니다." options: "옵션" -specifyUser: "사용자 지정" +specifyUser: "유저 지정" lookupConfirm: "조회 할까요?" openTagPageConfirm: "해시태그의 페이지를 열까요?" specifyHost: "호스트 지정" @@ -1294,12 +1294,12 @@ thereAreNChanges: "{n}건 변경이 있습니다." signinWithPasskey: "패스키로 로그인" unknownWebAuthnKey: "등록되지 않은 패스키입니다." passkeyVerificationFailed: "패스키 검증을 실패했습니다." -passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "입력된 패스키는 정상적이나, 비밀번호 없이 로그인 하는 기능이 비활성화 되어있습니다." messageToFollower: "팔로워에게 보낼 메시지" target: "대상" testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. 실제 환경에서는 사용하지 마세요." -prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)" -prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다." +prohibitedWordsForNameOfUser: "금지 단어 (유저명)" +prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 유저명에 있는 경우, 일반 유저는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 유저는 제한 대상에서 제외됩니다." yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다." yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요." thisContentsAreMarkedAsSigninRequiredByAuthor: "게시자에 의해 로그인해야 볼 수 있도록 설정되어 있습니다." @@ -1309,16 +1309,146 @@ availableRoles: "사용 가능한 역할" acknowledgeNotesAndEnable: "활성화 하기 전에 주의 사항을 확인했습니다." federationSpecified: "이 서버는 화이트 리스트 제도로 운영 중 입니다. 정해진 리모트 서버가 아닌 경우 연합되지 않습니다." federationDisabled: "이 서버는 연합을 하지 않고 있습니다. 리모트 서버 유저와 통신을 할 수 없습니다." +confirmOnReact: "리액션할 때 확인" +reactAreYouSure: "\" {emoji} \"로 리액션하시겠습니까?" +markAsSensitiveConfirm: "이 미디어를 민감한 미디어로 설정하시겠습니까?" +unmarkAsSensitiveConfirm: "이 미디어의 민감한 미디어 지정을 해제하시겠습니까?" +preferences: "환경설정" +accessibility: "접근성" +preferencesProfile: "설정 프로필" +copyPreferenceId: "설정한 ID를 복사" +resetToDefaultValue: "기본값으로 되돌리기" +overrideByAccount: "계정으로 덮어쓰기" +untitled: "제목 없음" +noName: "이름이 없습니다." +skip: "건너뛰기" +restore: "복원" +syncBetweenDevices: "장치간 동기화" +preferenceSyncConflictTitle: "서버에 설정값이 존재합니다." +preferenceSyncConflictText: "동기화를 활성화 한 항목의 설정 값은 서버에 저장되지만, 해당 항목은 이미 서버에 설정 값이 저장되어져 있습니다. 어느 쪽의 설정 값을 덮어씌울까요?" +preferenceSyncConflictChoiceServer: "서버 설정값" +preferenceSyncConflictChoiceDevice: "장치 설정값" +preferenceSyncConflictChoiceCancel: "동기화 취소" +paste: "붙여넣기" +emojiPalette: "이모지 팔레트" +postForm: "글 입력란" +textCount: "문자 수" +information: "정보" +chat: "채팅" +migrateOldSettings: "기존 설정 정보를 이전" +migrateOldSettings_description: "보통은 자동으로 이루어지지만, 어떤 이유로 인해 성공적으로 이전이 이루어지지 않는 경우 수동으로 이전을 실행할 수 있습니다. 현재 설정 정보는 덮어쓰게 됩니다." +compress: "압축" +right: "오른쪽" +bottom: "아래" +top: "위" +embed: "임베드" +settingsMigrating: "설정을 이전하는 중입니다. 잠시 기다려주십시오... (나중에 '환경설정 → 기타 → 기존 설정 정보를 이전'에서 수동으로 이전할 수도 있습니다)" +readonly: "읽기 전용" +goToDeck: "덱으로 돌아가기" +federationJobs: "연합 작업" +_chat: + noMessagesYet: "아직 메시지가 없습니다" + newMessage: "새로운 메시지" + individualChat: "개인 대화" + individualChat_description: "특정 유저와 일대일 채팅을 할 수 있습니다." + roomChat: "룸 채팅" + roomChat_description: "여러 명이 함께 채팅할 수 있습니다.\n또한, 개인 채팅을 허용하지 않은 유저와도 상대방이 수락하면 채팅을 할 수 있습니다." + createRoom: "룸을 생성" + inviteUserToChat: "유저를 초대하여 채팅을 시작하세요" + yourRooms: "생성한 룸" + joiningRooms: "참가 중인 룸" + invitations: "초대" + noInvitations: "초대장이 없습니다" + history: "이력" + noHistory: "기록이 없습니다" + noRooms: "룸이 없습니다" + inviteUser: "유저를 초대" + sentInvitations: "초대를 보내기" + join: "참여" + ignore: "무시" + leave: "룸을 떠나기" + members: "멤버" + searchMessages: "메시지 검색" + home: "홈" + send: "전송" + newline: "줄바꿈" + muteThisRoom: "이 룸을 뮤트" + deleteRoom: "룸을 삭제" + chatNotAvailableForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅이 활성화되어 있지 않습니다." + chatIsReadOnlyForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅은 읽기 전용입니다. 새로 쓰거나 채팅 룸을 만들거나 참가할 수 없습니다." + chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다." + cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다" + cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다." + chatWithThisUser: "채팅하기" + thisUserAllowsChatOnlyFromFollowers: "이 유저는 팔로워만 채팅을 할 수 있습니다." + thisUserAllowsChatOnlyFromFollowing: "이 유저는 이 유저가 팔로우하는 유저만 채팅을 허용합니다." + thisUserAllowsChatOnlyFromMutualFollowing: "이 유저는 상호 팔로우하는 유저만 채팅을 허용합니다." + thisUserNotAllowedChatAnyone: "이 유저는 다른 사람의 채팅을 받지 않습니다." + chatAllowedUsers: "채팅을 허용한 상대" + chatAllowedUsers_note: "내가 채팅 메시지를 보낸 상대와는 이 설정과 상관없이 채팅이 가능합니다." + _chatAllowedUsers: + everyone: "누구나" + followers: "자신의 팔로워만" + following: "자신이 팔로우한 유저만" + mutual: "상호 팔로우한 유저만" + none: "아무도 허락하지 않기" +_emojiPalette: + palettes: "팔레트" + enableSyncBetweenDevicesForPalettes: "팔레트의 디바이스 간 동기화를 활성화" + paletteForMain: "메인으로 사용할 팔레트" + paletteForReaction: "리액션으로 사용할 팔레트" +_settings: + driveBanner: "드라이브 관리, 사용량 확인, 파일 업로드에 관한 설정을 합니다." + pluginBanner: "플러그인을 사용하면 클라이언트 기능을 확장할 수 있습니다. 플러그인 설치와 개별적인 설정을 합니다." + notificationsBanner: "서버에서 받는 알림의 종류 및 범위, 푸시 알림 설정을 합니다." + api: "API" + webhook: "Webhook" + serviceConnection: "서비스 연동" + serviceConnectionBanner: "외부 앱, 서비스와 연결하기 위한 액세스 토큰과 웹 훅 관리 설정을 합니다." + accountData: "계정 데이터" + accountDataBanner: "계정 데이터의 아카이브를 추출하기/가져오기 하여 관리할 수 있습니다." + muteAndBlockBanner: "숨길 컨텐츠의 설정과, 특정 유저의 리액션을 제한하는 설정을 관리합니다." + accessibilityBanner: "좀 더 쾌적하게 사용할 수 있도록 클라이언트의 시각 및 움직임에 관한 개인화 설정을 합니다." + privacyBanner: "컨텐츠, 계정의 발견 범위, 팔로우 승인제 등의 계정의 프라이버시에 관한 설정을 합니다." + securityBanner: "비밀번호, 로그인 방법, OTP, 패스 키 등의 계정의 보안에 관련된 설정을 합니다." + preferencesBanner: "취향에 알맞는 클라이언트의 전체적인 동작을 설정합니다." + appearanceBanner: "취향에 알맞는 클라이언트의 디스플레이, 표시 방법에 관한 설정을 합니다." + soundsBanner: "클라이언트에서 재생할 소리에 대한 설정을 합니다." + timelineAndNote: "타임라인과 노트" + makeEveryTextElementsSelectable: "모든 텍스트 요소를 선택할 수 있도록 함" + makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 유저의 접근성이 나빠질 수도 있습니다." + useStickyIcons: "아이콘이 스크롤을 따라가도록 하기" + showNavbarSubButtons: "내비게이션 바에 보조 버튼 표시" + ifOn: "켜져 있을 때" + ifOff: "꺼져 있을 때" + enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화" + _chat: + showSenderName: "발신자 이름 표시" + sendOnEnter: "엔터로 보내기" +_preferencesProfile: + profileName: "프로필 이름" + profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요." + profileNameDescription2: "예: '메인PC', '스마트폰' 등" +_preferencesBackup: + autoBackup: "자동 백업" + restoreFromBackup: "백업으로 복구" + noBackupsFoundTitle: "백업을 찾을 수 없습니다" + noBackupsFoundDescription: "자동으로 생성된 백업은 찾을 수 없었지만, 수동으로 백업 파일을 저장한 경우 해당 파일을 가져와 복원할 수 있습니다." + selectBackupToRestore: "복원할 백업을 선택하세요" + youNeedToNameYourProfileToEnableAutoBackup: "자동 백업을 활성화하려면 프로필 이름을 설정해야 합니다." + autoPreferencesBackupIsNotEnabledForThisDevice: "이 장치에서 설정 자동 백업이 활성화되어 있지 않습니다." + backupFound: "설정 백업이 발견되었습니다" _accountSettings: requireSigninToViewContents: "콘텐츠 열람을 위해 로그인을 필수로 설정하기" requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다." requireSigninToViewContentsDescription2: "URL 미리보기(OGP), 웹페이지에 삽입, 노트 인용을 지원하지 않는 서버에서 볼 수 없게 됩니다." - requireSigninToViewContentsDescription3: "원격 서버에 연합된 콘텐츠에는 이러한 제한이 적용되지 않을 수 있습니다." + requireSigninToViewContentsDescription3: "리모트 서버에 연합된 콘텐츠에는 이러한 제한이 적용되지 않을 수 있습니다." makeNotesFollowersOnlyBefore: "과거 노트는 팔로워만 볼 수 있도록 설정하기" makeNotesFollowersOnlyBeforeDescription: "이 기능이 활성화되어 있는 동안, 설정된 날짜 및 시간보다 과거 또는 설정된 시간이 지난 노트는 팔로워만 볼 수 있게 됩니다. 비활성화하면 노트의 공개 상태도 원래대로 돌아갑니다." makeNotesHiddenBefore: "과거 노트 비공개로 전환하기" makeNotesHiddenBeforeDescription: "이 기능이 활성화되어 있는 동안 설정한 날짜 및 시간보다 과거 또는 설정한 시간이 지난 노트는 본인만 볼 수 있게(비공개로 전환) 됩니다. 비활성화하면 노트의 공개 상태도 원래대로 돌아갑니다." - mayNotEffectForFederatedNotes: "원격 서버에 연합된 노트에는 효과가 없을 수도 있습니다." + mayNotEffectForFederatedNotes: "리모트 서버에 연합된 노트에는 효과가 없을 수도 있습니다." + mayNotEffectSomeSituations: "여기서 설정하는 제한은 모더레이션이나 리모트 서버에서 볼 때 등 일부 환경에서는 적용되지 않을 수도 있습니다." notesHavePassedSpecifiedPeriod: "지정한 시간이 경과된 노트" notesOlderThanSpecifiedDateAndTime: "지정된 날짜 및 시간 이전의 노트" _abuseUserReport: @@ -1358,11 +1488,11 @@ _announcement: needConfirmationToRead: "읽음으로 표시하기 전에 확인하기" needConfirmationToReadDescription: "활성화하면 이 공지사항을 읽음으로 표시하기 전에 확인 알림창을 띄웁니다. '모두 읽음'의 대상에서도 제외됩니다." end: "공지에서 내리기" - tooManyActiveAnnouncementDescription: "공지사항이 너무 많을 경우, 사용자 경험에 영향을 끼칠 가능성이 있습니다. 오래된 공지사항은 아카이브하시는 것을 권장드립니다." + tooManyActiveAnnouncementDescription: "공지사항이 너무 많을 경우, 유저 경험에 영향을 끼칠 가능성이 있습니다. 오래된 공지사항은 아카이브하시는 것을 권장드립니다." readConfirmTitle: "읽음으로 표시합니까?" readConfirmText: "〈{title}〉의 내용을 읽음으로 표시합니다." shouldNotBeUsedToPresentPermanentInfo: "신규 유저의 이용 경험에 악영향을 끼칠 수 있으므로, 일시적인 알림 수단으로만 사용하고 고정된 정보에는 사용을 지양하는 것을 추천합니다." - dialogAnnouncementUxWarn: "다이얼로그 형태의 알림이 동시에 2개 이상 존재하는 경우, 사용자 경험에 악영향을 끼칠 수 있으므로 신중히 결정하십시오." + dialogAnnouncementUxWarn: "다이얼로그 형태의 알림이 동시에 2개 이상 존재하는 경우, 유저 경험에 악영향을 끼칠 수 있으므로 신중히 결정하십시오." silence: "조용히 알림" silenceDescription: "활성화하면 공지사항에 대한 알림이 가지 않게 되며, 확인 버튼을 누를 필요가 없게 됩니다." _initialAccountSetting: @@ -1414,7 +1544,7 @@ _initialTutorial: description3: "이 외에도, '리스트 타임라인'이나 '채널 타임라인' 등이 있습니다. 자세한 사항은 {link}에서 확인하실 수 있습니다." _postNote: title: "노트 게시 설정" - description1: "Misskey에 노트를 쓸 때에는 다양한 옵션을 설정할 수 있습니다. 노트를 작성하는 화면은 이렇게 생겼습니다." + description1: "Misskey에 노트를 게시할 때에는 다양한 옵션 설정이 가능합니다. 노트를 게시할 때 쓰이는 '글 입력란'은 이렇게 생겼습니다." _visibility: description: "노트를 볼 수 있는 사람을 제한할 수 있습니다." public: "모든 유저에게 공개합니다." @@ -1434,7 +1564,7 @@ _initialTutorial: _howToMakeAttachmentsSensitive: title: "첨부 파일을 열람주의로 설정하려면?" description: "서버의 가이드라인에 따라 필요한 이미지, 또는 그대로 노출되기에 부적절한 미디어는 '열람 주의'를 설정해 주세요." - tryThisFile: "이 작성 창에 첨부된 이미지를 열람 주의로 설정해 보세요!" + tryThisFile: "이 입력란에 첨부된 이미지를 열람 주의로 설정해 보세요!" _exampleNote: note: "낫또 뚜껑 뜯다가 실수했다…" method: "첨부 파일을 열람 주의로 설정하려면, 해당 파일을 클릭하여 메뉴를 열고, '열람주의로 설정'을 클릭합니다." @@ -1488,53 +1618,53 @@ _achievements: _types: _notes1: title: "미스키 계정 만들었어요" - description: "첫 노트를 작성했습니다" + description: "첫 노트를 게시했다" flavor: "Misskey에 어서 오세요!" _notes10: title: "몇 가지 노트" - description: "10개의 노트를 작성했습니다" + description: "10개의 노트를 게시했다" _notes100: title: "많은 노트" - description: "100개의 노트를 작성했습니다" + description: "100개의 노트를 게시했다" _notes500: title: "노트 범벅" - description: "500개의 노트를 작성했습니다" + description: "500개의 노트를 게시했다" _notes1000: title: "노트가 산더미" - description: "1,000개의 노트를 작성했습니다" + description: "1,000개의 노트를 게시했다" _notes5000: title: "솟아나는 노트" - description: "5,000개의 노트를 작성했습니다" + description: "5,000개의 노트를 게시했다" _notes10000: title: "슈퍼 노트" - description: "10,000개의 노트를 작성했습니다" + description: "10,000개의 노트를 게시했다" _notes20000: - title: "노트가 필요해요" - description: "20,000개의 노트를 작성했습니다" + title: "노트가 더 필요해요" + description: "20,000개의 노트를 게시했다" _notes30000: title: "노트노트노트" - description: "30,000개의 노트를 작성했습니다" + description: "30,000개의 노트를 게시했다" _notes40000: title: "노트 공장" - description: "40,000개의 노트를 작성했습니다" + description: "40,000개의 노트를 게시했다" _notes50000: title: "노트 행성" - description: "50,000개의 노트를 작성했습니다" + description: "50,000개의 노트를 게시했다" _notes60000: title: "노트 퀘이사" - description: "60,000개의 노트를 작성했습니다" + description: "60,000개의 노트를 게시했다" _notes70000: title: "노트 블랙홀" - description: "70,000개의 노트를 작성했습니다" + description: "70,000개의 노트를 게시했다" _notes80000: title: "노트 은하" - description: "80,000개의 노트를 작성했습니다" + description: "80,000개의 노트를 게시했다" _notes90000: title: "노트 우주" - description: "90,000개의 노트를 작성했습니다" + description: "90,000개의 노트를 게시했다" _notes100000: title: "ALL YOUR NOTE ARE BELONG TO US" - description: "100,000개의 노트를 작성했습니다" + description: "100,000개의 노트를 게시했다" flavor: "이렇게나 쓸 게 있어요?" _login3: title: "초보자 I" @@ -1559,181 +1689,181 @@ _achievements: flavor: "그 유저, 미스키스트이다" _login200: title: "단골 I" - description: "총 200일간 로그인했습니다" + description: "총 로그인한 날이 200일" _login300: title: "단골 II" - description: "총 300일간 로그인했습니다" + description: "총 로그인한 날이 300일" _login400: title: "단골 III" - description: "총 400일간 로그인했습니다" + description: "총 로그인한 날이 400일" _login500: title: "베테랑 I" - description: "총 500일간 로그인했습니다" + description: "총 로그인한 날이 500일" flavor: "제군, 나는 노트가 좋다" _login600: title: "베테랑 II" - description: "총 600일간 로그인했습니다" + description: "총 로그인한 날이 600일" _login700: title: "베테랑 III" - description: "총 700일간 로그인했습니다" + description: "총 로그인한 날이 700일" _login800: title: "노트 마스터 I" - description: "총 800일간 로그인했습니다" + description: "총 로그인한 날이 800일" _login900: title: "노트 마스터 II" - description: "총 900일간 로그인했습니다" + description: "총 로그인한 날이 900일" _login1000: title: "노트 마스터 III" - description: "총 1,000일간 로그인했습니다" + description: "총 로그인한 날이 1,000일" flavor: "Misskey를 사용해 주셔서 감사합니다!" _noteClipped1: title: "클립할 수밖에 없었어" - description: "처음으로 노트를 클립했습니다" + description: "처음으로 노트를 클립했다" _noteFavorited1: title: "별을 바라보는 자" - description: "처음으로 노트를 즐겨찾기했습니다" + description: "처음으로 노트를 즐겨찾기했다" _myNoteFavorited1: title: "별을 원하는 자" - description: "다른 사람이 당신의 노트를 즐겨찾기했습니다" + description: "다른 사람이 당신의 노트를 즐겨찾기했다" _profileFilled: title: "준비 완료" - description: "프로필 설정을 완료했습니다" + description: "프로필 설정을 완료했다" _markedAsCat: title: "나는 고양이다냥!" - description: "계정을 고양이로 설정했습니다냥" + description: "계정을 고양이로 설정했다냥" flavor: "냐냐냐냐냐냐아아아아앙!" _following1: title: "첫 팔로우" - description: "사용자를 처음으로 팔로우했습니다" + description: "유저를 처음으로 팔로우했다" _following10: title: "팔로우, 팔로우" - description: "10명의 사용자를 팔로우했습니다" + description: "10명의 유저를 팔로우했다" _following50: title: "친구 잔뜩" - description: "50명의 사용자를 팔로우했습니다" + description: "50명의 유저를 팔로우했다" _following100: title: "주소록 한 권으론 부족해" - description: "100명의 사용자를 팔로우했습니다" + description: "100명의 유저를 팔로우했다" _following300: title: "친구가 넘쳐나" - description: "300명의 사용자를 팔로우했습니다" + description: "300명의 유저를 팔로우했다" _followers1: title: "첫 팔로워" - description: "사용자가 처음으로 팔로잉했습니다" + description: "유저가 처음으로 팔로잉했다" _followers10: title: "팔로우 미!" - description: "10명의 사용자가 팔로우했습니다" + description: "10명의 유저가 팔로우했다" _followers50: title: "이곳저곳" - description: "50명의 사용자가 팔로우했습니다" + description: "50명의 유저가 팔로우했다" _followers100: title: "인기왕" - description: "100명의 사용자가 팔로우했습니다" + description: "100명의 유저가 팔로우했다" _followers300: title: "줄 좀 서봐요" - description: "100명의 사용자가 팔로우했습니다" + description: "100명의 유저가 팔로우했다" _followers500: title: "기지국" - description: "500명의 사용자가 팔로우했습니다" + description: "500명의 유저가 팔로우했다" _followers1000: title: "유명인사" - description: "1,000명의 사용자가 팔로우했습니다" + description: "1,000명의 유저가 팔로우했다" _collectAchievements30: title: "도전 과제 콜렉터" - description: "30개의 도전과제를 획득했습니다" + description: "30개의 도전과제를 획득했다" _viewAchievements3min: title: "저 도전과제 좋아해요" - description: "도전 과제 목록을 3분 이상 쳐다봤습니다" + description: "도전 과제 목록을 3분 이상 쳐다봤다" _iLoveMisskey: title: "I Love Misskey" - description: "\"I ❤ #Misskey\"를 포스트했습니다" + description: "\"I ❤ #Misskey\"를 게시했다" flavor: "Misskey를 이용해 주셔서 감사합니다! ― 개발 팀" _foundTreasure: title: "보물찾기" - description: "숨겨진 보물을 발견했습니다" + description: "숨겨진 보물을 발견했다" _client30min: title: "잠시 쉬어요" - description: "클라이언트를 시작하고 30분이 경과하였습니다" + description: "클라이언트를 시작하고 30분이 경과했다" _client60min: title: "No \"Miss\" in Misskey" - description: "클라이언트를 시작하고 60분이 경과하였습니다" + description: "클라이언트를 시작하고 60분이 경과했다" _noteDeletedWithin1min: title: "있었는데요 없었습니다" - description: "노트를 포스트한 후 1분 이내에 삭제했습니다" + description: "노트를 게시한 후 1분 이내에 삭제했다" _postedAtLateNight: title: "올빼미" - description: "한밤중에 노트를 포스트했습니다" + description: "한밤중에 노트를 게시했다" flavor: "잠 좀 자세요. 걱정돼요." _postedAt0min0sec: title: "정각" - description: "0분 0초 정각에 노트를 작성했습니다" + description: "0분 0초 정각에 노트를 게시했다" flavor: "째깍 째깍 째깍 땡!" _selfQuote: title: "혼잣말" - description: "자기 노트를 인용했습니다" + description: "자기 노트를 인용했다" _htl20npm: title: "타임라인 폭주 중" - description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었습니다" + description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었다" _viewInstanceChart: title: "애널리스트" - description: "서버의 차트를 열었습니다" + description: "서버의 차트를 열었다" _outputHelloWorldOnScratchpad: title: "Hello, world!" - description: "스크래치패드에서 hello world를 출력했습니다" + description: "스크래치패드에서 hello world를 출력했다" _open3windows: title: "멀티 윈도우" - description: "3개 이상의 창을 열었습니다" + description: "3개 이상의 창을 열었다" _driveFolderCircularReference: title: "순환 참조" - description: "드라이브 폴더에 스스로를 넣게 했습니다" + description: "드라이브 폴더에 스스로를 넣게 했다" _reactWithoutRead: title: "읽고 답하긴 하시는 건가요?" - description: "100자가 넘는 노트를 작성한 지 3초 안에 반응했어요" + description: "100자가 넘는 노트를 게시한 지 3초 안에 리액션했다" _clickedClickHere: - title: "여기를 누르세요" - description: "여기를 눌렀습니다" + title: "여길 눌러보세요" + description: "여기를 눌렀다" _justPlainLucky: title: "그냥 운이 좋았어" - description: "매 10초마다 0.01%의 확률로 달성됩니다" + description: "매 10초마다 0.01%의 확률로 달성된다" _setNameToSyuilo: title: "신 콤플렉스" - description: "이름을 syuilo로 설정했습니다" + description: "이름을 syuilo로 설정했다" _passedSinceAccountCreated1: title: "1주년" - description: "계정을 생성하고 1년이 지났습니다" + description: "계정을 생성하고 1년이 지났다" _passedSinceAccountCreated2: title: "2주년" - description: "계정을 생성하고 2년이 지났습니다" + description: "계정을 생성하고 2년이 지났다" _passedSinceAccountCreated3: title: "3주년" - description: "계정을 생성하고 3년이 지났습니다" + description: "계정을 생성하고 3년이 지났다" _loggedInOnBirthday: title: "생일 축하합니다!" - description: "생일에 로그인했습니다" + description: "생일에 로그인했다" _loggedInOnNewYearsDay: title: "새해 복 많이 받으세요" - description: "새해 첫 날에 로그인했습니다" + description: "새해 첫 날에 로그인했다" flavor: "올해에도 저희 서버에 관심을 가져 주셔서 감사합니다" _cookieClicked: title: "쿠키를 클릭하는 게임" - description: "쿠키를 클릭했습니다" + description: "쿠키를 클릭했다" flavor: "소프트웨어 착각하지 않으셨나요?" _brainDiver: title: "Brain Diver" - description: "Brain Diver로의 링크를 첨부했습니다" + description: "Brain Diver로의 링크를 첨부했다" flavor: "Misskey-Misskey La-Tu-Ma" _smashTestNotificationButton: title: "테스트 과잉" - description: "매우 짧은 시간 안에 알림 테스트를 여러 번 수행했습니다" + description: "매우 짧은 시간 안에 알림 테스트를 여러 번 수행했다" _tutorialCompleted: title: "Misskey 입문자 과정 수료증" - description: "튜토리얼을 완료했습니다" + description: "튜토리얼을 완료했다" _bubbleGameExplodingHead: title: "🤯" description: "버블 게임에서 가장 큰 물건을 내놓았다" _bubbleGameDoubleExplodingHead: title: "더블 🤯" - description: "버블게임에서 가장 큰 물건 2개를 동시에 내놓았다." + description: "버블게임에서 가장 큰 물건 2개를 동시에 내놓았다" flavor: "이 정도만 도시락통에 🤯 🤯 조금만 더" _role: new: "새 역할 생성" @@ -1743,7 +1873,7 @@ _role: permission: "역할 권한" descriptionOfPermission: "조정자는 기본적인 조정 작업을 진행할 수 있습니다.\n관리자는 서버의 모든 설정을 변경할 수 있습니다." assignTarget: "할당 대상" - descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있습니다.\n조건부를 선택하면 조건을 설정해 일치하는 사용자를 자동으로 포함되게 할 수 있습니다." + descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있습니다.\n조건부를 선택하면 조건을 설정해 일치하는 유저를 자동으로 포함되게 할 수 있습니다." manual: "수동" manualRoles: "수동 역할" conditional: "조건부" @@ -1751,7 +1881,7 @@ _role: condition: "조건" isConditionalRole: "조건부 역할입니다." isPublic: "역할 공개" - descriptionOfIsPublic: "역할에 할당된 사용자를 누구나 볼 수 있습니다. 또한 사용자 프로필에 이 역할이 표시됩니다." + descriptionOfIsPublic: "역할에 할당된 유저를 누구나 볼 수 있습니다. 또한 유저 프로필에 이 역할이 표시됩니다." options: "옵션" policies: "정책" baseRole: "기본 역할" @@ -1764,8 +1894,10 @@ _role: descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다." displayOrder: "표시 순서" descriptionOfDisplayOrder: "값이 클 수록 UI에서 먼저 표시됩니다." + preserveAssignmentOnMoveAccount: "이전 대상 계정에도 할당 상태 전달" + preserveAssignmentOnMoveAccount_description: "켜면 이 역할이 부여된 계정이 이전될 때 마이그레이션 대상 계정에도 이 역할이 승계됩니다." canEditMembersByModerator: "모더레이터의 역할 수정 허용" - descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." + descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 유저를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." priority: "우선순위" _priority: low: "낮음" @@ -1791,8 +1923,8 @@ _role: webhookMax: "만들 수 있는 Webhook 수" clipMax: "만들 수 있는 클립 수" noteEachClipsMax: "클립에 넣을 수 있는 노트 수" - userListMax: "만들 수 있는 사용자 리스트 수" - userEachUserListsMax: "사용자 리스트에 넣을 수 있는 사용자 수" + userListMax: "만들 수 있는 유저 리스트 수" + userEachUserListsMax: "유저 리스트에 넣을 수 있는 유저 수" rateLimitFactor: "요청 빈도 제한" descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다." canHideAds: "광고 숨기기" @@ -1804,23 +1936,24 @@ _role: canImportFollowing: "팔로우 가져오기 허용" canImportMuting: "뮤트 목록 가져오기 허용" canImportUserLists: "리스트 목록 가져오기 허용" + chatAvailability: "채팅을 허락" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" - isLocal: "로컬 사용자" - isRemote: "원격 사용자" - isCat: "고양이 사용자" - isBot: "봇 사용자" - isSuspended: "정지된 사용자" - isLocked: "잠금 계정 사용자" - isExplorable: "‘계정을 쉽게 발견하도록 하기’를 활성화한 사용자" + isLocal: "로컬 유저" + isRemote: "리모트 유저" + isCat: "고양이 유저" + isBot: "봇 유저" + isSuspended: "정지된 유저" + isLocked: "잠금 계정 유저" + isExplorable: "‘계정을 쉽게 발견하도록 하기’를 활성화한 유저" createdLessThan: "가입한 지 다음 일수 이내인 유저" createdMoreThan: "가입한 지 다음 일수 이상인 유저" followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" - followersMoreThanOrEq: "팔로워 수가 다음보다 많은 사용자" + followersMoreThanOrEq: "팔로워 수가 다음보다 많은 유저" followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저" - followingMoreThanOrEq: "팔로잉 수가 다음보다 많은 사용자" + followingMoreThanOrEq: "팔로잉 수가 다음보다 많은 유저" notesLessThanOrEq: "노트 수가 다음 이하인 유저" - notesMoreThanOrEq: "노트 수가 다음보다 많은 사용자" + notesMoreThanOrEq: "노트 수가 다음보다 많은 유저" and: "다음을 모두 만족" or: "다음을 하나라도 만족" not: "다음을 만족하지 않음" @@ -1862,7 +1995,7 @@ _ad: adsSettings: "광고 표시 설정" notesPerOneAd: "실시간으로 갱신되는 타임라인에서 광고를 노출시키는 간격 (노트 당)" setZeroToDisable: "0으로 지정하면 실시간 타임라인에서의 광고를 비활성화합니다" - adsTooClose: "광고의 표시 간격이 매우 작아, 사용자 경험에 부정적인 영향을 미칠 수 있습니다." + adsTooClose: "광고의 표시 간격이 매우 작아, 유저 경험에 부정적인 영향을 미칠 수 있습니다." _forgotPassword: enterEmail: "여기에 계정에 등록한 메일 주소를 입력해 주세요. 입력한 메일 주소로 비밀번호 재설정 링크를 발송합니다." ifNoEmail: "메일 주소를 등록하지 않은 경우, 관리자에 문의해 주십시오." @@ -1967,6 +2100,7 @@ _theme: installed: "{name} 테마가 설치되었습니다" installedThemes: "설치된 테마" builtinThemes: "표준 테마" + instanceTheme: "서버 테마" alreadyInstalled: "이미 설치된 테마입니다" invalid: "테마 형식이 올바르지 않습니다" make: "테마 만들기" @@ -1999,7 +2133,6 @@ _theme: header: "헤더" navBg: "사이드바 배경" navFg: "사이드바 텍스트" - navHoverFg: "사이드바 텍스트 (호버)" navActive: "사이드바 텍스트 (활성)" navIndicator: "사이드바 인디케이터" link: "링크" @@ -2022,17 +2155,15 @@ _theme: buttonHoverBg: "버튼 배경 (호버)" inputBorder: "입력 필드 테두리" driveFolderBg: "드라이브 폴더 배경" - wallpaperOverlay: "배경화면 오버레이" badge: "배지" messageBg: "대화 배경" - accentDarken: "강조 색상 (어두움)" - accentLighten: "강조 색상 (밝음)" fgHighlighted: "강조된 텍스트" _sfx: note: "새 노트" noteMy: "내 노트" notification: "알림" reaction: "리액션 선택" + chatMessage: "채팅 메시지" _soundSettings: driveFile: "드라이브에 있는 오디오를 사용" driveFileWarn: "드라이브에 있는 파일을 선택하세요." @@ -2119,7 +2250,7 @@ _permissions: "write:pages": "페이지를 수정합니다" "read:page-likes": "페이지의 좋아요를 확인합니다" "write:page-likes": "페이지에 좋아요를 추가하거나 취소합니다" - "read:user-groups": "사용자 그룹 보기" + "read:user-groups": "유저 그룹 보기" "write:user-groups": "유저 그룹을 만들거나, 초대하거나, 이름을 변경하거나, 양도하거나, 삭제합니다" "read:channels": "채널을 보기" "write:channels": "채널을 추가하거나 삭제합니다" @@ -2131,23 +2262,23 @@ _permissions: "write:flash": "Play를 조작합니다" "read:flash-likes": "Play의 좋아요를 봅니다" "write:flash-likes": "Play의 좋아요를 조작합니다" - "read:admin:abuse-user-reports": "사용자 신고 보기" - "write:admin:delete-account": "사용자 계정 삭제하기" - "write:admin:delete-all-files-of-a-user": "모든 사용자 파일 삭제하기" + "read:admin:abuse-user-reports": "유저 신고 보기" + "write:admin:delete-account": "유저 계정 삭제하기" + "write:admin:delete-all-files-of-a-user": "모든 유저 파일 삭제하기" "read:admin:index-stats": "데이터베이스 색인 정보 보기" "read:admin:table-stats": "데이터베이스 테이블 정보 보기" - "read:admin:user-ips": "사용자 IP 주소 보기" + "read:admin:user-ips": "유저 IP 주소 보기" "read:admin:meta": "인스턴스 메타데이터 보기" - "write:admin:reset-password": "사용자 비밀번호 재설정하기" - "write:admin:resolve-abuse-user-report": "사용자 신고 처리하기" + "write:admin:reset-password": "유저 비밀번호 재설정하기" + "write:admin:resolve-abuse-user-report": "유저 신고 처리하기" "write:admin:send-email": "이메일 보내기" "read:admin:server-info": "서버 정보 보기" "read:admin:show-moderation-log": "조정 기록 보기" - "read:admin:show-user": "사용자 개인정보 보기" - "write:admin:suspend-user": "사용자 정지하기" - "write:admin:unset-user-avatar": "사용자 아바타 삭제하기" - "write:admin:unset-user-banner": "사용자 배너 삭제하기" - "write:admin:unsuspend-user": "사용자 정지 해제하기" + "read:admin:show-user": "유저 개인정보 보기" + "write:admin:suspend-user": "유저 정지하기" + "write:admin:unset-user-avatar": "유저 아바타 삭제하기" + "write:admin:unset-user-banner": "유저 배너 삭제하기" + "write:admin:unsuspend-user": "유저 정지 해제하기" "write:admin:meta": "인스턴스 메타데이터 수정하기" "write:admin:user-note": "조정 기록 수정하기" "write:admin:roles": "역할 수정하기" @@ -2161,15 +2292,15 @@ _permissions: "write:admin:avatar-decorations": "아바타 꾸미기 수정하기" "read:admin:avatar-decorations": "아바타 꾸미기 보기" "write:admin:federation": "연합 정보 수정하기" - "write:admin:account": "사용자 계정 수정하기" - "read:admin:account": "사용자 정보 보기" + "write:admin:account": "유저 계정 수정하기" + "read:admin:account": "유저 정보 보기" "write:admin:emoji": "이모지 수정하기" "read:admin:emoji": "이모지 보기" "write:admin:queue": "작업 대기열 수정하기" "read:admin:queue": "작업 대기열 정보 보기" "write:admin:promo": "홍보 기록 수정하기" - "write:admin:drive": "사용자 드라이브 수정하기" - "read:admin:drive": "사용자 드라이브 정보 보기" + "write:admin:drive": "유저 드라이브 수정하기" + "read:admin:drive": "유저 드라이브 정보 보기" "read:admin:stream": "관리자용 Websocket API 사용하기" "write:admin:ad": "광고 수정하기" "read:admin:ad": "광고 보기" @@ -2179,6 +2310,8 @@ _permissions: "read:clip-favorite": "클립의 좋아요 보기" "read:federation": "연합 정보 불러오기" "write:report-abuse": "위반 내용 신고하기" + "write:chat": "대화를 시작하거나 메시지를 보냅니다" + "read:chat": "채팅 열람하기" _auth: shareAccessTitle: "어플리케이션의 접근 허가" shareAccess: "‘{name}’에서 계정에 접근하는 것을 허용하시겠습니까?" @@ -2189,7 +2322,7 @@ _auth: callback: "앱으로 돌아갑니다" accepted: "접근 권한이 부여되었습니다." denied: "접근이 거부되었습니다" - scopeUser: "다음 사용자로 활동하고 있습니다." + scopeUser: "다음 유저로 활동하고 있습니다." pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인하십시오." byClickingYouWillBeRedirectedToThisUrl: "접근을 허용하면 자동으로 다음 URL로 이동합니다." _antennaSources: @@ -2226,7 +2359,7 @@ _widgets: postForm: "글 입력란" slideshow: "슬라이드 쇼" button: "버튼" - onlineUsers: "온라인 사용자" + onlineUsers: "온라인 유저" jobQueue: "작업 대기열" serverMetric: "서버 통계" aiscript: "AiScript 콘솔" @@ -2236,7 +2369,8 @@ _widgets: _userList: chooseList: "리스트 선택" clicker: "클리커" - birthdayFollowings: "오늘이 생일인 사용자" + birthdayFollowings: "오늘이 생일인 유저" + chat: "채팅" _cw: hide: "숨기기" show: "더 보기" @@ -2288,7 +2422,7 @@ _postForm: f: "작성해주시길 기다리고 있어요..." _profile: name: "이름" - username: "사용자 이름" + username: "유저명" description: "자기소개" youCanIncludeHashtags: "해시 태그를 포함할 수 있습니다." metadata: "추가 정보" @@ -2319,7 +2453,7 @@ _charts: apRequest: "요청" usersIncDec: "유저 수 증감" usersTotal: "유저 수 합계" - activeUsers: "활동 사용자 수" + activeUsers: "활동 유저 수" notesIncDec: "노트 수 증감" localNotesIncDec: "로컬 노트 수 증감" remoteNotesIncDec: "리모트 노트 수 증감" @@ -2330,8 +2464,8 @@ _charts: storageUsageTotal: "스토리지 사용량 합계" _instanceCharts: requests: "요청" - users: "사용자 수 차이" - usersTotal: "누적 사용자 수" + users: "유저 수 차이" + usersTotal: "누적 유저 수" notes: "노트 수 증감" notesTotal: "누적 노트 수" ff: "팔로잉/팔로워 증감" @@ -2427,35 +2561,40 @@ _notification: newNote: "새 게시물" unreadAntennaNote: "안테나 {name}" roleAssigned: "역할이 부여 되었습니다." + chatRoomInvitationReceived: "채팅 룸에 초대받았습니다" emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" achievementEarned: "도전 과제를 달성했습니다" testNotification: "알림 테스트" checkNotificationBehavior: "알림 표시를 체크하기" sendTestNotification: "테스트 알림 보내기" notificationWillBeDisplayedLikeThis: "알림이 이렇게 표시됩니다" - reactedBySomeUsers: "{n}명이 반응했습니다" + reactedBySomeUsers: "{n}명이 리액션했습니다" likedBySomeUsers: "{n}명이 좋아요를 했습니다" renotedBySomeUsers: "{n}명이 리노트했습니다" followedBySomeUsers: "{n}명에게 팔로우됨" flushNotification: "알림 이력을 초기화" exportOfXCompleted: "{x} 추출에 성공했습니다." login: "로그인 알림이 있습니다" + createToken: "액세스 토큰이 생성되었습니다" + createTokenDescription: "만약 기억이 나지 않는다면 '{text}'를 통해 액세스 토큰을 삭제해 주세요." _types: all: "전부" - note: "사용자의 새 글" + note: "유저의 새 글" follow: "팔로잉" mention: "멘션" reply: "답글" renote: "리노트" quote: "인용" - reaction: "반응" + reaction: "리액션" pollEnded: "투표가 종료됨" receiveFollowRequest: "팔로우 요청을 받았을 때" followRequestAccepted: "팔로우 요청이 승인되었을 때" - roleAssigned: "역할이 부여 됨" + roleAssigned: "역할이 부여됨" + chatRoomInvitationReceived: "채팅 룸에 초대받음" achievementEarned: "도전 과제 획득" exportCompleted: "추출을 성공함" login: "로그인" + createToken: "액세스 토큰 만들기" test: "알림 테스트" app: "연동된 앱을 통한 알림" _actions: @@ -2465,6 +2604,9 @@ _notification: _deck: alwaysShowMainColumn: "메인 칼럼 항상 표시" columnAlign: "칼럼 정렬" + columnGap: "칼럼 간 여백" + deckMenuPosition: "덱 메뉴 위치" + navbarPosition: "내비게이션 바 위치" addColumn: "칼럼 추가" newNoteNotificationSettings: "새 노트 알림 설정" configureColumn: "칼럼 설정" @@ -2483,6 +2625,7 @@ _deck: useSimpleUiForNonRootPages: "루트 이외의 페이지로 접속한 경우 UI 간략화하기" usedAsMinWidthWhenFlexible: "'폭 자동 조정'이 활성화된 경우 최소 폭으로 사용됩니다" flexible: "폭 자동 조정" + enableSyncBetweenDevicesForProfiles: "프로파일 정보의 디바이스 간 동기화를 활성화" _columns: main: "메인" widgets: "위젯" @@ -2494,6 +2637,7 @@ _deck: mentions: "받은 멘션" direct: "다이렉트" roleTimeline: "역할 타임라인" + chat: "채팅" _dialog: charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {max}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" @@ -2535,10 +2679,10 @@ _abuseReport: mail: "이메일" webhook: "Webhook" _captions: - mail: "모더레이터 권한을 가진 사용자의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" + mail: "모더레이터 권한을 가진 유저의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" webhook: "지정한 SystemWebhook에 알림을 보냅니다 (신고를 받은 때와 해결했을 때에 송신)" keywords: "키워드" - notifiedUser: "알릴 사용자" + notifiedUser: "알릴 유저" notifiedWebhook: "사용할 Webhook" deleteConfirm: "수신자를 삭제하시겠습니까?" _moderationLogTypes: @@ -2557,11 +2701,11 @@ _moderationLogTypes: deleteDriveFile: "파일 삭제" deleteNote: "노트 삭제" createGlobalAnnouncement: "전역 공지사항 생성" - createUserAnnouncement: "사용자 공지사항 만들기" + createUserAnnouncement: "유저에게 공지사항 만들기" updateGlobalAnnouncement: "모든 공지사항 수정" - updateUserAnnouncement: "사용자 공지사항 수정" + updateUserAnnouncement: "유저의 공지사항 수정" deleteGlobalAnnouncement: "모든 공지사항 삭제" - deleteUserAnnouncement: "사용자 공지사항 삭제" + deleteUserAnnouncement: "유저의 공지사항 삭제" resetPassword: "비밀번호 재설정" suspendRemoteInstance: "리모트 서버를 정지" unsuspendRemoteInstance: "리모트 서버의 정지를 해제" @@ -2589,7 +2733,9 @@ _moderationLogTypes: deleteAccount: "계정을 삭제" deletePage: "페이지를 삭제" deleteFlash: "Play를 삭제" - deleteGalleryPost: "갤러리 포스트를 삭제" + deleteGalleryPost: "갤러리 게시물을 삭제" + deleteChatRoom: "채팅 룸 삭제" + updateProxyAccountDescription: "프록시 계정의 설명 업데이트" _fileViewer: title: "파일 상세" type: "파일 유형" @@ -2603,10 +2749,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "제공자를 신뢰할 수 있는 경우에만 설치하십시오." _plugin: title: "이 플러그인을 설치하시겠습니까?" - metaTitle: "플러그인 정보" _theme: title: "이 테마를 설치하시겠습니까?" - metaTitle: "테마 정보" _meta: base: "기본 컬러 스키마" _vendorInfo: @@ -2803,7 +2947,7 @@ _embedCodeGen: _selfXssPrevention: warning: "경고" title: "“이 화면에 뭔가를 붙여넣어라\"는 것은 모두 사기입니다." - description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다." + description1: "여기에 무언가를 붙여넣으면 악의적인 유저에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다." description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오." description3: "자세한 내용은 여기를 확인해 주세요. {link}" _followRequest: @@ -2822,8 +2966,6 @@ _remoteLookupErrors: _responseInvalid: title: "유효하지 않은 반응입니다." description: "이 서버와 통신할 수 있지만, 데이터가 올바르지 않습니다." - _responseInvalidIdHostNotMatch: - description: "입력된 URI과 실제 URI가 다릅니다. 제 3자 서버를 통한 리모트 컨텐츠를 조회하는 경우, 원래 서버 측에서 받아올 수 있는 URI를 사용하여 조회하시길 바랍니다." _noSuchObject: title: "찾을 수 없습니다" description: "요구된 리소스를 찾을 수 없습니다. URI를 다시 한 번 확인해보세요." @@ -2838,5 +2980,25 @@ _captcha: title: "CAPTCHA 검증을 실패했습니다." text: "설정이 올바른지 다시 한 번 확인해보세요." _unknown: - title: "CAPTCHA 에러" - text: "알 수 없는 에러가 발생했습니다." + title: "CAPTCHA 오류" + text: "알 수 없는 오류가 발생했습니다." +_bootErrors: + title: "로딩이 실패함" + serverError: "잠시 기다렸다가 다시 로드해도 여전히 문제가 해결되지 않으면 아래 Error ID와 함께 서버 관리자에게 연락해 주세요." + solution: "다음과 같은 방법으로 해결할 수 있습니다." + solution1: "브라우저 및 OS를 최신 버전으로 업데이트하기" + solution2: "광고 차단 비활성화하기" + solution3: "브라우저 캐시 지우기" + solution4: "(Tor Browser) dom.webaudio.enabled를 true로 설정하세요" + otherOption: "기타 옵션" + otherOption1: "클라이언트 설정 및 캐시 삭제" + otherOption2: "간편 클라이언트 실행" + otherOption3: "복구 툴 실행" +_search: + searchScopeAll: "전체" + searchScopeLocal: "로컬" + searchScopeServer: "서버 지정" + searchScopeUser: "유저 지정" + pleaseEnterServerHost: "서버의 호스트를 입력해 주세요." + pleaseSelectUser: "유저를 선택해주세요" + serverHostPlaceholder: "예: misskey.example.com" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 2d55c289aa..455a71f302 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -223,7 +223,6 @@ remove: "ລຶບ" removed: "ລຶບແລ້ວ" resetAreYouSure: "ຣີ​ເຊັດບໍ?" saved: "ບັນທຶກແລ້ວ" -messaging: "ແຊັຕ" upload: "ອັບໂຫຼດ" keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" fromDrive: "ຈາກ Drive" @@ -233,7 +232,6 @@ uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດແລ້ວ" explore: "ສຳຫຼວດ" messageRead: "ອ່ານແລ້ວ" -startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" nUsersRead: "ອ່ານໂດຍ {n}" agree: "ຍອມຮັບ" termsOfService: "ເງື່ອນໄຂການບໍລິການ" @@ -394,6 +392,12 @@ searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" replies: "ຕອບ​ກັບ" renotes: "Renote" +information: "ກ່ຽວກັບ" +_chat: + invitations: "ເຊີນ" + noHistory: "​ບໍ່​ມີປະຫວັດ" + members: "ສະມາຊິກ" + home: "ໜ້າຫຼັກ" _delivery: stop: "ໂຈະ" _type: @@ -477,3 +481,5 @@ _moderationLogTypes: _remoteLookupErrors: _noSuchObject: title: "ບໍ່ພົບ" +_search: + searchScopeAll: "ທັງໝົດ" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 685094b4a5..cc59d04595 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -5,6 +5,7 @@ introMisskey: "Welkom! Misskey is een open source, gedecentraliseerde microblogd poweredByMisskeyDescription: "{name} is één van de services die door het open source platform Misskey wordt geleverd (het wordt ook wel een \"Misskey server genmoemd\")." monthAndDay: "{day} {month}" search: "Zoeken" +reset: "Herstellen" notifications: "Meldingen" username: "Gebruikersnaam" password: "Wachtwoord" @@ -48,6 +49,7 @@ pin: "Vastmaken aan profielpagina" unpin: "Losmaken van profielpagina" copyContent: "Kopiëren inhoud" copyLink: "Kopiëren link" +copyRemoteLink: "Remote-link kopiëren" copyLinkRenote: "" delete: "Verwijderen" deleteAndEdit: "Verwijderen en bewerken" @@ -63,6 +65,7 @@ copyFileId: "Kopieer veld ID" copyFolderId: "Kopieer folder ID" copyProfileUrl: "Kopieer profiel URL" searchUser: "Zoeken een gebruiker" +searchThisUsersNotes: "Notities van deze gebruiker doorzoeken" reply: "Antwoord" loadMore: "Laad meer" showMore: "Toon meer" @@ -115,6 +118,8 @@ renotedToX: "Renoted naar {name}" cantRenote: "Dit bericht kan niet worden herdeeld" cantReRenote: "Een herdeling kan niet worden herdeeld" quote: "Quote" +inChannelRenote: "Alleen-kanaal Renote" +inChannelQuote: "Alleen-kanaal Citaat" renoteToChannel: "Renote naar kanaal" renoteToOtherChannel: "Renote naar ander kanaal" pinnedNote: "Vastgemaakte notitie" @@ -129,14 +134,19 @@ emojiPicker: "Emoji kiezer" pinnedEmojisForReactionSettingDescription: "Kies de emojis die als eerste getoond worden tijdens het reageren" pinnedEmojisSettingDescription: "Kies de emojis die als eerste getoond worden tijdens het reageren" emojiPickerDisplay: "Emoji kiezer weergave" +overwriteFromPinnedEmojisForReaction: "Overschrijven met reactieinstellingen" +overwriteFromPinnedEmojis: "Overschrijven met algemene instellingen" reactionSettingDescription2: "Sleep om opnieuw te ordenen, Klik om te verwijderen, Druk op \"+\" om toe te voegen" rememberNoteVisibility: "Vergeet niet de notitie zichtbaarheidsinstellingen" attachCancel: "Verwijder bijlage" +deleteFile: "Bestand verwijderen" markAsSensitive: "Markeren als NSFW" unmarkAsSensitive: "Geen NSFW" enterFileName: "Invoeren bestandsnaam" mute: "Dempen" unmute: "Stop dempen" +renoteMute: "Renotes dempen" +renoteUnmute: "Dempen Renotes opheffen" block: "Blokkeren" unblock: "Deblokkeren" suspend: "Opschorten" @@ -146,7 +156,11 @@ unblockConfirm: "Ben je zeker dat je deze account wil blokkeren?" suspendConfirm: "Ben je zeker dat je deze account wil suspenderen?" unsuspendConfirm: "Ben je zeker dat je deze account wil opnieuw aanstellen?" selectList: "Kies een lijst." +editList: "Lijst bewerken" +selectChannel: "Kanaal selecteren" selectAntenna: "Kies een antenne" +editAntenna: "Antenne bewerken" +createAntenna: "Antenne aanmaken" selectWidget: "Kies een widget" editWidgets: "Bewerk widgets" editWidgetsExit: "Klaar" @@ -158,6 +172,10 @@ emojiUrl: "URL emoji" addEmoji: "Toevoegen emoji" settingGuide: "Aanbevolen instellingen" cacheRemoteFiles: "Externe bestanden cachen" +cacheRemoteFilesDescription: "Als deze instelling uitgeschakeld is worden bestanden altijd direct van remote servers geladen. Hiermee wordt opslagruimte bespaard, maar doordat er geen thumbnails worden gegenereerd, zal netwerkverkeer toenemen." +youCanCleanRemoteFilesCache: "Klik op de 🗑️ knop in de bestandsbeheerweergave om de cache te wissen." +cacheRemoteSensitiveFiles: "Gevoelige bestanden van externe instances in de cache bewaren" +cacheRemoteSensitiveFilesDescription: "Als deze instelling is uitgeschakeld, worden gevoelige bestanden op afstand direct vanuit de instantie op afstand geladen zonder caching." flagAsBot: "Markeer dit account als een robot." flagAsBotDescription: "Als dit account van een programma wordt beheerd, zet deze vlag aan. Het aanzetten helpt andere ontwikkelaars om bijvoorbeeld onbedoelde feedback loops te doorbreken of om Misskey meer geschikt te maken." flagAsCat: "Markeer dit account als een kat." @@ -166,8 +184,13 @@ flagShowTimelineReplies: "Toon antwoorden op de tijdlijn." flagShowTimelineRepliesDescription: "Als je dit vlag aanzet, toont de tijdlijn ook antwoorden op andere en niet alleen jouw eigen notities." autoAcceptFollowed: "Accepteer verzoeken om jezelf te volgen vanzelf als je de verzoeker al volgt." addAccount: "Account toevoegen" +reloadAccountsList: "Accountlijst opnieuw laden" loginFailed: "Aanmelding mislukt." showOnRemote: "Toon op de externe instantie." +continueOnRemote: "Verder op remote server" +chooseServerOnMisskeyHub: "Kies een server van de Misskey Hub" +specifyServerHost: "Serverhost uitkiezen" +inputHostName: "Domein invullen" general: "Algemeen" wallpaper: "Achtergrond" setWallpaper: "Achtergrond instellen" @@ -178,6 +201,7 @@ followConfirm: "Weet je zeker dat je {name} wilt volgen?" proxyAccount: "Proxy account" proxyAccountDescription: "Een proxy-account is een account dat onder bepaalde voorwaarden fungeert als externe volger voor gebruikers. Als een gebruiker bijvoorbeeld een externe gebruiker aan de lijst toevoegt, wordt de activiteit van de externe gebruiker niet aan de server geleverd als geen lokale gebruiker die gebruiker volgt, dus het proxy-account volgt in plaats daarvan." host: "Server" +selectSelf: "Mezelf kiezen" selectUser: "Kies een gebruiker" recipient: "Ontvanger" annotation: "Reacties" @@ -192,6 +216,8 @@ perHour: "Per uur" perDay: "Per dag" stopActivityDelivery: "Stop met versturen activiteiten" blockThisInstance: "Blokkeer deze server" +silenceThisInstance: "Instantie dempen" +mediaSilenceThisInstance: "Media van deze server dempen" operations: "Verwerkingen" software: "Software" version: "Versie" @@ -211,6 +237,12 @@ clearCachedFiles: "Cache opschonen" clearCachedFilesConfirm: "Weet je zeker dat je alle externe bestanden in de cache wilt verwijderen?" blockedInstances: "Geblokkeerde servers" blockedInstancesDescription: "Maak een lijst van de servers die moeten worden geblokkeerd, gescheiden door regeleinden. Geblokkeerde servers kunnen niet meer communiceren met deze server." +silencedInstances: "Gedempte instanties" +silencedInstancesDescription: "Geef de hostnamen van de servers die je wil dempen op, elk op hun eigen regel. Alle accounts die bij de opgegeven servers horen worden als gedempt behandeld, kunnen alleen maar volgverzoeken maken, en kunnen lokale accounts niet vermelden als ze niet gevolgd worden. Geblokkeerde servers worden hier niet door beïnvloed." +mediaSilencedInstances: "Media-gedempte servers" +mediaSilencedInstancesDescription: "Geef de hostnamen van de servers die je wil media-dempen op, elk op hun eigen regel. Alle accounts die bij de opgegeven servers horen worden als gedempt behandeld, en kunnen geen eigen emojis gebruiken. Geblokkeerde servers worden hier niet door beïnvloed." +federationAllowedHosts: "Servers die mogen federeren " +federationAllowedHostsDescription: "Geef de hostnamen van de servers die mogen federeren op, elk op hun eigen regel." muteAndBlock: "Gedempt en geblokkeerd" mutedUsers: "Gedempte gebruikers" blockedUsers: "Geblokkeerde gebruikers" @@ -255,8 +287,8 @@ removed: "Succesvol verwijderd" removeAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" deleteAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" resetAreYouSure: "Resetten?" +areYouSure: "Weet je het zeker?" saved: "Opgeslagen" -messaging: "Chat" upload: "Uploaden" keepOriginalUploading: "Origineel beeld behouden." keepOriginalUploadingDescription: "Bewaar de originele versie bij het uploaden van afbeeldingen. Indien uitgeschakeld, wordt bij het uploaden een alternatieve versie voor webpublicatie genereert." @@ -269,9 +301,13 @@ uploadFromUrlMayTakeTime: "Het kan even duren voordat het uploaden voltooid is." explore: "Verkennen" messageRead: "Lezen" noMoreHistory: "Er is geen verdere geschiedenis" -startMessaging: "Start een gesprek" +startChat: "Chat starten" nUsersRead: "gelezen door {n}" agreeTo: "Ik stem in met {0}" +agree: "Akkoord" +agreeBelow: "Ik ga akkoord met de volgende" +basicNotesBeforeCreateAccount: "Belangrijke informatie" +termsOfService: "Gebruiksvoorwaarden" start: "Aan de slag" home: "Startpagina" remoteUserCaution: "Aangezien deze gebruiker van een externe server afkomstig is, kan de weergegeven informatie onvolledig zijn." @@ -296,12 +332,15 @@ selectFile: "Kies een bestand" selectFiles: "Selecteer bestanden" selectFolder: "Kies een map" selectFolders: "Kies mappen" +fileNotSelected: "Geen bestand geselecteerd" renameFile: "Wijzig bestandsnaam" folderName: "Mapnaam" createFolder: "Map aanmaken" renameFolder: "Map hernoemen" deleteFolder: "Map verwijderen" +folder: "Map" addFile: "Bestand toevoegen" +showFile: "Bestanden weergeven" emptyDrive: "Jouw Drive is leeg." emptyFolder: "Deze map is leeg" unableToDelete: "Kan niet worden verwijderd" @@ -314,6 +353,7 @@ copyUrl: "URL kopiëren" rename: "Hernoemen" avatar: "Avatar" banner: "Banner" +displayOfSensitiveMedia: "Weergave van gevoelige media" whenServerDisconnected: "Wanneer de verbinding met de server wordt onderbroken" disconnectedFromServer: "Verbinding met de server onderbroken." reload: "Verversen" @@ -351,14 +391,20 @@ bannerUrl: "Banner URL" backgroundImageUrl: "URL afbeelding" basicInfo: "Basisinformatie" pinnedUsers: "Vastgeprikte gebruikers" +pinnedUsersDescription: "Een lijst met gebruikersnamen, gescheiden door regeleinden, die moet worden vastgemaakt in het tabblad “Verkennen”" pinnedPages: "Vastgeprikte pagina's" +pinnedPagesDescription: "Voer de paden in van de Pagina's die je aan de bovenste pagina van deze instantie wilt vastmaken, gescheiden door regeleinden." +pinnedClipId: "ID van de clip die moet worden vastgepind" pinnedNotes: "Vastgemaakte notitie" hcaptcha: "hCaptcha" enableHcaptcha: "Inschakelen hCaptcha" hcaptchaSiteKey: "Site sleutel" hcaptchaSecretKey: "Geheime sleutel" +mcaptcha: "mCaptcha" +enableMcaptcha: "mCaptcha activeren" mcaptchaSiteKey: "Site sleutel" mcaptchaSecretKey: "Geheime sleutel" +mcaptchaInstanceUrl: "mCaptcha server-URL" recaptcha: "reCAPTCHA" enableRecaptcha: "Inschakelen reCAPTCHA" recaptchaSiteKey: "Site sleutel" @@ -367,12 +413,21 @@ turnstile: "Tourniquet" enableTurnstile: "Inschakelen tourniquet" turnstileSiteKey: "Site sleutel" turnstileSecretKey: "Geheime sleutel" +avoidMultiCaptchaConfirm: "Het gebruik van meerdere Captcha-systemen kan interferentie tussen deze systemen veroorzaken. Wil je de andere Captcha-systemen die momenteel actief zijn uitschakelen? Als je wilt dat ze ingeschakeld blijven, druk dan op annuleren." antennas: "Antennes" manageAntennas: "Antennes beheren" name: "Naam" antennaSource: "Bron antenne" antennaKeywords: "Sleutelwoorden" antennaExcludeKeywords: "Blokkeerwoorden" +antennaExcludeBots: "Bot-accounts uitsluiten" +antennaKeywordsDescription: "Scheid met spaties voor een EN-voorwaarde of met regeleinden voor een OF-voorwaarde." +notifyAntenna: "Houd een notificatie bij nieuwe notities" +withFileAntenna: "Alleen notities met bestanden" +excludeNotesInSensitiveChannel: "Sluit notities uit van gevoelige kanalen" +enableServiceworker: "Activeer pushmeldingen in de browser" +antennaUsersDescription: "Lijst één gebruikersnaam per regel" +caseSensitive: "Hoofdlettergevoelig" withReplies: "Antwoorden toevoegen" connectedTo: "De volgende accounts zijn verbonden" notesAndReplies: "Berichten en reacties" @@ -393,18 +448,30 @@ about: "Over" aboutMisskey: "Over Misskey" administrator: "Beheerder" token: "Token" +2fa: "Twee factor authenticatie" +setupOf2fa: "Tweefactorauthenticatie instellen" +totp: "Verificatie-App" +totpDescription: "Log in via de verificatie-app met het eenmalige wachtwoord" moderator: "Moderator" moderation: "Moderatie" +moderationNote: "Moderatienotitie" +moderationNoteDescription: "Voer hier notities in. Deze zijn alleen zichtbaar voor de moderators." +addModerationNote: "Moderatienotitie toevoegen" +moderationLogs: "Moderatieprotocollen" nUsersMentioned: "Vermeld door {n} gebruikers" +securityKeyAndPasskey: "Beveiligings- en pasjessleutels" securityKey: "Beveiligingssleutel" lastUsed: "Laatst gebruikt" +lastUsedAt: "Laatst gebruikt: {t}" unregister: "Uitschrijven" passwordLessLogin: "Inloggen zonder wachtwoord" +passwordLessLoginDescription: "Maakt aanmelden zonder wachtwoord mogelijk met een beveiligingstoken of -wachtsleutel" resetPassword: "Wachtwoord terugzetten" newPasswordIs: "Het nieuwe wachtwoord is „{password}”." reduceUiAnimation: "Verminder beweging in de UI" share: "Delen" notFound: "Niet gevonden" +notFoundDescription: "Er is geen pagina gevonden onder deze URL." uploadFolder: "Standaardmap voor uploaden" markAsReadAllNotifications: "Markeer alle meldingen als gelezen" markAsReadAllUnreadNotes: "Markeer alle berichten als gelezen" @@ -423,7 +490,53 @@ retype: "Opnieuw invoeren" noteOf: "Notitie van {user}" quoteAttached: "Citaat" quoteQuestion: "Toevoegen als citaat?" +attachAsFileQuestion: "De tekst op het klembord is te lang. Wilt u het als een tekstbestand bijvoegen?" +onlyOneFileCanBeAttached: "Per bericht kan slechts één bestand worden bijgevoegd" +signinRequired: "Gelieve te registreren of in te loggen om verder te gaan" +signinOrContinueOnRemote: "Ga naar je eigen instantie of registreer je/log in op deze server om door te gaan." invitations: "Uitnodigen" +invitationCode: "Uitnodigingscode" +checking: "Wordt gecheckt ..." +available: "Beschikbaar" +unavailable: "Onbeschikbaar" +usernameInvalidFormat: "Je kunt kleine letters, hoofdletters, cijfers en onderstrepingstekens gebruiken." +tooShort: "Te kort" +tooLong: "Te lang" +weakPassword: "Zwak wachtwoord" +normalPassword: "Redelijke wachtwoord" +strongPassword: "Sterk wachtwoord" +passwordMatched: "Lucifers" +passwordNotMatched: "Komt niet overeen" +signinWith: "Aanmelden met {x}" +signinFailed: "Inloggen mislukt. Controleer gebruikersnaam en wachtwoord." +or: "Of" +language: "Taal" +uiLanguage: "Taal van gebruikersinterface" +aboutX: "Over {x}" +emojiStyle: "Emoji-stijl" +native: "Inheems" +menuStyle: "Menustijl" +style: "Stijl" +drawer: "Lade" +popup: "Pop-up" +showNoteActionsOnlyHover: "Toon notitiemenu alleen bij muisaanwijzer" +showReactionsCount: "Zie het aantal reacties op notities" +noHistory: "Geen geschiedenis gevonden" +signinHistory: "Inloggeschiedenis" +enableAdvancedMfm: "Uitgebreide MFM activeren" +enableAnimatedMfm: "Geanimeerde MFM activeren" +doing: "In uitvoering..." +category: "Categorie" +tags: "Aliassen" +docSource: "Broncode van dit document" +createAccount: "Gebruikersaccount maken" +existingAccount: "Bestaand gebruikersaccount" +regenerate: "Regenereer" +fontSize: "Lettergrootte" +mediaListWithOneImageAppearance: "Hoogte van medialijsten met slechts één afbeelding" +limitTo: "Beperken tot {x}" +noFollowRequests: "Je hebt geen lopende volgverzoeken" +openImageInNewTab: "Afbeeldingen in nieuw tabblad openen" dashboard: "Overzicht" local: "Lokaal" remote: "Remote" @@ -438,20 +551,396 @@ promote: "Promoot" numberOfDays: "Aantal dagen" hideThisNote: "Verberg deze notitie" showFeaturedNotesInTimeline: "Laat featured notities in tijdlijn zien" +objectStorage: "Object Storage" +useObjectStorage: "Object Storage gebruiken" +objectStorageBaseUrl: "Basis-URL" +objectStorageBaseUrlDesc: "De URL die wordt gebruikt als referentie. Als je een CDN of proxy gebruikt, voer dan de URL daarvan in. Gebruik voor S3 ‘https://.s3.amazonaws.com’. Gebruik voor GCS of vergelijkbaar ‘https://storage.googleapis.com/’." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Geef de bucketnaam op die bij je provider wordt gebruikt." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "Bestanden worden opgeslagen in de mappen onder deze prefix." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "Laat dit leeg als je AWS S3 gebruikt, anders geef je het eindpunt op als ‘’ of ‘:’, afhankelijk van de service die je gebruikt." +objectStorageRegion: "Region" +objectStorageRegionDesc: "Voer een regio in zoals “xx-east-1”. Als je provider geen onderscheid maakt tussen regio's, voer dan “us-east-1” in. Laat leeg als je AWS-configuratiebestanden of omgevingsvariabelen gebruikt." +objectStorageUseSSL: "SSL gebruiken" +objectStorageUseSSLDesc: "Deactiveer dit als u geen HTTPS gebruikt voor API-verbindingen" +objectStorageUseProxy: "Verbinden via proxy" +objectStorageUseProxyDesc: "Deactiveer dit als u geen proxy wilt gebruiken voor verbindingen met de API" +objectStorageSetPublicRead: "Instellen op “public-read” op upload" +s3ForcePathStyleDesc: "Als s3ForcePathStyle is geactiveerd, moet de bucketnaam niet worden opgegeven in de hostnaam van de URL, maar in het pad van de URL. Deze optie moet mogelijk worden geactiveerd als services zoals een zelfbediende Minio-instantie worden gebruikt." +serverLogs: "Serverprotocollen" +deleteAll: "Alles verwijderen" +showFixedPostForm: "Het postingformulier bovenaan de tijdbalk weergeven" +showFixedPostFormInChannel: "Het postingformulier bovenaan de tijdbalk weergeven (Kanalen)" +withRepliesByDefaultForNewlyFollowed: "Toon replies van nieuw gevolgde gebruikers standaard in de tijdlijn" +newNoteRecived: "Er zijn nieuwe notities" +sounds: "Geluiden" sound: "Geluid" +listen: "Luisteren" +none: "Niets" +showInPage: "Weergeven in een pagina" +popout: "Pop-Up" +volume: "Volume" +masterVolume: "Hoofdvolume" +notUseSound: "Geluid uitschakelen" +useSoundOnlyWhenActive: "Geluid alleen inschakelen wanneer Misskey actief is" +details: "Details" +renoteDetails: "Renote Details" +chooseEmoji: "Emoji selecteren" +unableToProcess: "De operatie kan niet worden voltooid." +recentUsed: "Recent gebruikt" +install: "Installeren" +uninstall: "Deinstalleren" +installedApps: "Geautoriseerde toepassingen" +nothing: "Niets te zien hier" +installedDate: "Geautoriseerd at" +lastUsedDate: "Laatst gebruikt at" +state: "Status" +sort: "Sorteren" +ascendingOrder: "Oplopende volgorde" +descendingOrder: "Aflopende volgorde" +scratchpad: "Testomgeving" +scratchpadDescription: "De testomgeving biedt een gebied voor AiScript experimenten. Daar kunt u AiScript schrijven en uitvoeren en de effecten ervan op Misskey controleren." +uiInspector: "UI-inspecteur" +uiInspectorDescription: "De lijst met servers van UI-componenten kan worden bekeken in de cache. De UI-component wordt gegenereerd door de functie Ui:C:" +output: "Uitvoer" +script: "Script" +disablePagesScript: "AiScript uitschakelen op pagina's" +updateRemoteUser: "Gebruikersinformatie bijwerken" +unsetUserAvatar: "Avatar verwijderen" +unsetUserAvatarConfirm: "Weet je zeker dat je je avatar wil verwijderen?" +unsetUserBanner: "Banner verwijderen" +unsetUserBannerConfirm: "Weet je zeker dat je je banner wil verwijderen?" +deleteAllFiles: "Alle bestanden verwijderen" +deleteAllFilesConfirm: "Wil je echt alle bestanden verwijderen?" +removeAllFollowing: "Ontvolg alle gevolgde gebruikers" +removeAllFollowingDescription: "Door dit uit te voeren worden alle accounts van {host} ontvolgd. Voer dit uit als de instantie bijvoorbeeld niet meer bestaat." +userSuspended: "Deze gebruiker is geschorst." +userSilenced: "Deze gebruiker is instantiebreed gedempt." +yourAccountSuspendedTitle: "Deze account is geschorst" +yourAccountSuspendedDescription: "Dit gebruikersaccount is geschorst omdat het de gebruiksvoorwaarden van deze server heeft geschonden. Neem contact op met de operator voor meer informatie. Maak geen nieuwe gebruikersaccount aan." +tokenRevoked: "Ongeldig token" +tokenRevokedDescription: "Het token is verlopen. Log opnieuw in." +accountDeleted: "Het gebruikersaccount is verwijderd" +accountDeletedDescription: "Deze account is verwijderd." +menu: "Menu" +divider: "Scheider" +addItem: "Element toevoegen" +rearrange: "Sorteren" +relays: "Relays" +addRelay: "Relay toevoegen" +inboxUrl: "Inbox-URL" +addedRelays: "Toegevoegd Relays" +serviceworkerInfo: "Moet worden geactiveerd voor pushmeldingen." +deletedNote: "Verwijderde notitie" +invisibleNote: "Privé notitie" +enableInfiniteScroll: "Automatisch meer laden" +visibility: "Zichtbaarheid" +poll: "Peiling" +useCw: "Inhoudswaarschuwing gebruiken" +enablePlayer: "Videospeler openen" +disablePlayer: "Videospeler sluiten" +expandTweet: "Notitie uitklappen" +themeEditor: "Thema-editor" +description: "Beschrijving" +describeFile: "Beschrijving toevoegen" +enterFileDescription: "Beschrijving invoeren" +author: "Auteur" +leaveConfirm: "Er zijn niet-opgeslagen wijzigingen. Wil je ze verwijderen?" +manage: "Beheer" +plugins: "Plugins" +preferencesBackups: "Instellingen Back-ups" +deck: "Dek" +undeck: "Dek verlaten" +useBlurEffectForModal: "Vervagingseffect gebruiken voor modals" +useFullReactionPicker: "Volledige reaktieselectier gebruiken" +width: "Breedte" +height: "Hoogte" +large: "Groot" +medium: "Medium" +small: "Klein" +generateAccessToken: "Toegangstoken genereren" +permission: "Machtigingen" +adminPermission: "Administratorrechten" +enableAll: "Alle activeren" +disableAll: "Alle deactiveren" +tokenRequested: "Toegang verlenen tot het gebruikersaccount" +pluginTokenRequestedDescription: "Deze plugin kan de hier geconfigureerde autorisaties gebruiken." +notificationType: "Type melding" +edit: "Bewerken" +emailServer: "Email-Server" +enableEmail: "Email distributie inschakelen" +emailConfigInfo: "Wordt gebruikt om je email te bevestigen tijdens het aanmelden of als je je wachtwoord bent vergeten" +email: "Email" +emailAddress: "Email adres" +smtpConfig: "SMTP-server configuratie" smtpHost: "Server" +smtpPort: "Poort" smtpUser: "Gebruikersnaam" smtpPass: "Wachtwoord" +emptyToDisableSmtpAuth: "Laat gebruikersnaam en wachtwoord leeg om SMTP-authenticatie uit te schakelen." +smtpSecure: "Impliciet SSL/TLS gebruiken voor SMTP-verbindingen" +smtpSecureInfo: "Schakel dit uit bij gebruik van STARTTLS" +testEmail: "Emailversand testen" +wordMute: "Woord dempen" +wordMuteDescription: "Minimaliseert notities die het gespecificeerde woord of zin bevatten. Geminimaliseerde notities kunnen worden weergegeven door er op te klikken." +hardWordMute: "Harde woorddemping" +showMutedWord: "Gedempte woorden weergeven" +hardWordMuteDescription: "Verbert notities die het gespecificeerde woord of zin bevatten. In tegenstelling tot woorddemping wordt de notitie volledig verborgen." +regexpError: "Fout in reguliere expressie" +regexpErrorDescription: "Er is een fout opgetreden in de reguliere expressie op regel {line} van uw {tab} woord dempen:" +instanceMute: "Instantie dempers" +userSaysSomething: "{name} zei iets" +userSaysSomethingAbout: "{name} zei iets over '{word}'" +makeActive: "Activeren" +display: "Weergave" +copy: "Kopiëren" +copiedToClipboard: "Naar het klembord gekopieerd" +metrics: "Metrieken" +overview: "Overzicht" +logs: "Protocollen" +delayed: "Vertraagd" +database: "Database" +channel: "Kanalen" +create: "Creëer" +notificationSetting: "Instellingen meldingen" +notificationSettingDesc: "Selecteer het type meldingen dat moet worden weergegeven." +useGlobalSetting: "Globale instelling gebruiken" +useGlobalSettingDesc: "Als deze optie is ingeschakeld, worden de meldingsinstellingen van je account gebruikt. Als deze optie uitgeschakeld is, kunnen individuele configuraties worden gemaakt." +other: "Ander" +regenerateLoginToken: "Login token opnieuw genereren" +regenerateLoginTokenDescription: "Regenereren van het token dat intern wordt gebruikt om in te loggen. Dit is normaal gezien niet nodig. Alle apparaten worden afgemeld tijdens het regenereren." +theKeywordWhenSearchingForCustomEmoji: "Dit is het keyword dat gebruikt wordt bij het zoeken naar eigen emojis." +setMultipleBySeparatingWithSpace: "Scheid elementen met een spatie om meerdere instellingen te configureren." +fileIdOrUrl: "Bestands-ID of URL" +behavior: "Gedrag" +sample: "Voorbeeld" +abuseReports: "Meldt" +reportAbuse: "Meld" +reportAbuseRenote: "Meld renote" +reportAbuseOf: "Meld {name}" +fillAbuseReportDescription: "Vul s.v.p. de details in over deze melding. Geef, als het over een specifieke notitie gaat, ook de URL op." +abuseReported: "Uw rapport is verzonden. Hartelijk dank." +reporter: "Verslaggever" +reporteeOrigin: "Oorsprong van de gemelde persoon" +reporterOrigin: "Verslaggever Oorsprong" +send: "Stuur" +openInNewTab: "In nieuw tabblad openen" +openInSideView: "In zijaanzicht openen" +defaultNavigationBehaviour: "Standaard navigatie gedrag" +editTheseSettingsMayBreakAccount: "Het wijzigen van deze instellingen kan je account beschadigen." +instanceTicker: "Instantie-informatie van notities" +waitingFor: "Wachten op {x}" +random: "Willekeurig" +system: "Systeem" +switchUi: "UI omschakelen" +desktop: "Desktop" +clip: "Clip aanmaken" +createNew: "Nieuwe aanmaken" +optional: "Optioneel" +createNewClip: "Nieuwe clip aanmaken" +unclip: "Van clip verwijderen" +confirmToUnclipAlreadyClippedNote: "Deze notitie is al toegevoegd aan de clip “{name}”. Wil je deze uit deze clip verwijderen?" +public: "Openbare" +private: "Privé" +i18nInfo: "Misskey wordt in veel verschillende talen vertaald door vrijwilligers. Je kunt helpen op {link}" +manageAccessTokens: "Toegangstokens beheren" +accountInfo: "Informatie over gebruikersaccount" +notesCount: "Aantal notities" +repliesCount: "Aantal verzonden replies" +renotesCount: "Aantal verzonden renotes" +repliedCount: "Aantal ontvangen replies" +renotedCount: "Aantal ontvangen renotes" +followingCount: "Aantal gevolgde accounts" +followersCount: "Aantal volgers" +sentReactionsCount: "Aantal verzonden reacties" +receivedReactionsCount: "Aantal ontvangen reacties" +pollVotesCount: "Aantal verzonden peiling stemmen" +pollVotedCount: "Aantal ontvangen peiling stemmen" +yes: "Ja" +no: "Nee" +driveFilesCount: "Aantal bestanden in station" +driveUsage: "Schijfruimtegebruik" +noCrawle: "Crawler-indexering verwerpen" +noCrawleDescription: "Vraag zoekmachines om je eigen profielpagina, notities, pagina's, enz. niet te indexeren." +lockedAccountInfo: "Tenzij je de zichtbaarheid van je notities instelt op “Alleen volgers”, zijn je notities zichtbaar voor iedereen, zelfs als je vereist dat volgers handmatig worden goedgekeurd." +alwaysMarkSensitive: "Markeer media standaard als gevoelig" +loadRawImages: "Toon altijd originele afbeeldingen in plaats van miniaturen" +disableShowingAnimatedImages: "Speel geen geanimeerde afbeeldingen af" +highlightSensitiveMedia: "Markeer gevoelige media" +verificationEmailSent: "Er is een bevestigingsmail naar uw e-mailadres verzonden. Ga naar de link in de e-mail om het verificatieproces te voltooien." +notSet: "Niet geconfigureerd" +emailVerified: "Emailadres bevestigd" +noteFavoritesCount: "Aantal notities gemarkeerd als favoriet" +pageLikesCount: "Aantal gelikete pagina's" +pageLikedCount: "Aantal ontvangen pagina-likes" +contact: "Contact" +useSystemFont: "Het standaardlettertype van het systeem gebruiken" +clips: "Clips" +experimentalFeatures: "Experimentele functionaliteiten" +experimental: "Experimentele" +thisIsExperimentalFeature: "Dit is een experimentele functie. De functionaliteit kan worden gewijzigd en werkt mogelijk niet zoals bedoeld." +developer: "Ontwikkelaar" +makeExplorable: "Gebruikersaccount zichtbaar maken in “Verkennen”" +makeExplorableDescription: "Als deze optie is uitgeschakeld, is uw gebruikersaccount niet zichtbaar in het gedeelte “Verkennen”." +showGapBetweenNotesInTimeline: "Een gat tussen noten op de tijdlijn weergeven" +duplicate: "Dupliceren" +left: "Links" +center: "Center" +wide: "Breed" +narrow: "Smal" +reloadToApplySetting: "Deze instelling gaat pas in nadat de pagina herladen is. Nu herladen?" +needReloadToApply: "Deze instelling wordt van kracht nadat de pagina is vernieuwd." +showTitlebar: "Titelbalk weergeven" clearCache: "Cache opschonen" +onlineUsersCount: "{n} Gebruikers zijn online" +nUsers: "{n} Gebruikers" +nNotes: "{n} Notities" +sendErrorReports: "Foutrapporten sturen" +sendErrorReportsDescription: "Als u deze optie inschakelt, wordt gedetailleerde foutinformatie met Misskey gedeeld wanneer zich een probleem voordoet. Dit helpt de kwaliteit van Misskey te verbeteren.\nDit omvat informatie zoals de versie van uw OS, welke browser u gebruikt, uw activiteit in Misskey, enz." +myTheme: "Mijn thema" +backgroundColor: "Achtergrondkleur" +accentColor: "Accentkleur" +textColor: "Tekstkleur" +saveAs: "Opslaan als…" +advanced: "Geavanceerd" +advancedSettings: "Geavanceerde instellingen" +value: "Waarde" +createdAt: "Aangemaakt at" +updatedAt: "Laatst gewijzigd at" +saveConfirm: "Wijzigingen opslaan?" +deleteConfirm: "Echt verwijderen?" +invalidValue: "Ongeldige waarde." +registry: "Registry" +closeAccount: "Gebruikersaccount sluiten" +currentVersion: "Huidige versie" +latestVersion: "Nieuwste versie" +youAreRunningUpToDateClient: "Je gebruikt de nieuwste versie van je client." +newVersionOfClientAvailable: "Er is een nieuwere versie van je client beschikbaar." +usageAmount: "Gebruik" +capacity: "Capaciteit" +inUse: "Gebruikt" +editCode: "Code bewerken" +apply: "Toepassen" +receiveAnnouncementFromInstance: "Meldingen ontvangen van deze instantie" +emailNotification: "E-mailmeldingen" +publish: "Publiceren" +inChannelSearch: "In kanaal zoeken" +useReactionPickerForContextMenu: "Open reactieselectie door rechts te klikken" +typingUsers: "{users} is/zijn aan het schrijven..." +jumpToSpecifiedDate: "Naar een specifieke datum springen" +showingPastTimeline: "Momenteel wordt een oude tijdlijn weergeven" +clear: "Terugkeren" +markAllAsRead: "Alles als gelezen markeren" +goBack: "Terug" +unlikeConfirm: "Wil je echt je like verwijderen?" +fullView: "Volledig zicht" +quitFullView: "Volledig zicht verlaten" +addDescription: "Beschrijving toevoegen" +userPagePinTip: "Je kunt hier notities tonen door “Vastmaken aan profiel” te selecteren in het menu van de individuele notities." +notSpecifiedMentionWarning: "Deze notitie bevat verwijzingen naar gebruikers die niet zijn geselecteerd als ontvangers" info: "Over" +userInfo: "Gebruikersinformatie" +unknown: "Onbekend" +onlineStatus: "Online status" +hideOnlineStatus: "Online status verbergen" +hideOnlineStatusDescription: "Het verbergen van je online status vermindert het nut van functies zoals zoeken." +online: "Online" +active: "Actief" +offline: "Offline" +notRecommended: "Niet aanbevolen" +botProtection: "Beveiliging tegen bots" +instanceBlocking: "Geblokkeerde/gedempte Instanties" +selectAccount: "Gebruikersaccount selecteren" +switchAccount: "Account wisselen" +enabled: "Ingeschakeld" +disabled: "Uitgeschakeld" +quickAction: "Snelle acties" user: "Gebruikers" +administration: "Beheer" +accounts: "Gebruikersaccounts" +switch: "Wissel" +noMaintainerInformationWarning: "Operatorinformatie is niet geconfigureerd." +noInquiryUrlWarning: "Contact-URL niet opgegeven" +noBotProtectionWarning: "Bescherming tegen bots is niet geconfigureerd." +configure: "Configureer" +postToGallery: "Nieuw galerijbericht maken" +postToHashtag: "Post naar deze hashtag" +gallery: "Galerij" +recentPosts: "Recente berichten" +popularPosts: "Populair berichten" +shareWithNote: "Delen met notitie" +ads: "Advertenties" +expiration: "Deadline" +startingperiod: "Start" +memo: "Memo" +priority: "Prioriteit" +high: "Hoge" +middle: "Medium" +low: "Lage" +emailNotConfiguredWarning: "E-mailadres niet ingesteld." +ratio: "Verhouding" +previewNoteText: "Show voorproefje" +customCss: "Aangepaste CSS" +customCssWarn: "Gebruik deze instelling alleen als je weet wat het doet. Ongeldige invoer kan ertoe leiden dat de client niet meer normaal functioneert." +global: "Globaal" +squareAvatars: "Toon profielfoto's as vierkant" +sent: "Verzonden" +received: "Ontvangen" +searchResult: "Zoekresultaten" +hashtags: "Hashtags" +troubleshooting: "Probleemoplossing" +useBlurEffect: "Vervagingseffecten in de UI gebruike" +learnMore: "Meer leren" +misskeyUpdated: "Misskey is bijgewerkt!" +whatIsNew: "Wijzigingen tonen" +translate: "Vertalen" +translatedFrom: "Vertaald uit {x}" +accountDeletionInProgress: "De verwijdering van je gebruikersaccount wordt momenteel verwerkt." +usernameInfo: "Een naam die kan worden gebruikt om je gebruikersaccount op deze server te identificeren. Je kunt het alfabet (a~z, A~Z), cijfers (0~9) of underscores (_) gebruiken. Gebruikersnamen kunnen later niet worden gewijzigd." +aiChanMode: "Ai Mode" +devMode: "Ontwikkelaar modus" +keepCw: "Inhoudswaarschuwingen behouden" +pubSub: "Pub/Sub Gebruikersaccounts" +lastCommunication: "Laatste communicatie" +resolved: "Opgelost" +unresolved: "Onopgelost" +breakFollow: "Volger verwijderen" +breakFollowConfirm: "Deze volger echt weghalen?" +itsOn: "Ingeschakeld" +itsOff: "Uitgeschakeld" +on: "Op" +off: "Uit" +emailRequiredForSignup: "Vereist e-mailadres voor aanmelding" +unread: "Ongelezen" +filter: "Filter" +controlPanel: "Controlepaneel" +manageAccounts: "Gebruikersaccounts beheren" +makeReactionsPublic: "Reactiegeschiedenis publiceren" +makeReactionsPublicDescription: "Hierdoor wordt de lijst met al je eerdere reacties openbaar." +classic: "Classic" muteThread: "Discussies dempen " unmuteThread: "Dempen van discussie ongedaan maken" +followingVisibility: "Zichtbaarheid van gevolgden" +followersVisibility: "Zichtbaarheid van volgers" +continueThread: "Bekijk draad voortzetting" +deleteAccountConfirm: "Je gebruikersaccount wordt onherroepelijk verwijderd. Wil je nog steeds doorgaan?" +incorrectPassword: "Onjuist wachtwoord." +incorrectTotp: "Het eenmalige wachtwoord is incorrect of verlopen" +voteConfirm: "Bevestig je je stem op “{choice}”?" hide: "Verbergen" +useDrawerReactionPickerForMobile: "Toon reactiekiezer als lade op mobiel" +welcomeBackWithName: "Welkom terug, {name}" +clickToFinishEmailVerification: "Druk op [{ok}] om de e-mailbevestiging af te ronden." searchByGoogle: "Zoeken" +threeMonths: "3 maanden" +oneYear: "1 jaar" +threeDays: "3 dagen" cropImage: "Afbeelding bijsnijden" cropImageAsk: "Bijsnijdengevraagd" file: "Bestanden" +account: "Gebruikersaccounts" pushNotification: "Pushberichten" subscribePushNotification: "Push meldingen inschakelen" unsubscribePushNotification: "Pushberichten uitschakelen" @@ -459,20 +948,59 @@ pushNotificationAlreadySubscribed: "Pushberichtrn al ingeschakeld" windowMaximize: "Maximaliseren" windowRestore: "Herstellen" loggedInAsBot: "Momenteel als bot ingelogd" +show: "Weergave" +correspondingSourceIsAvailable: "De bijbehorende broncode is beschikbaar bij {anchor}" +invalidParamErrorDescription: "De aanvraagparameters zijn ongeldig. Dit komt meestal door een bug, maar kan ook omdat de invoer te lang is of iets dergelijks." +collapseRenotes: "Renotes die je al gezien hebt, inklappen" +collapseRenotesDescription: "Klapt notities in waar je al op gereageerd hebt of die je al gerenotet hebt." +prohibitedWords: "Verboden woorden" +prohibitedWordsDescription: "Activeert een foutmelding als er geprobeerd wordt een notitie met de ingestelde woorden te plaatsen. Meerdere woorden kunnen worden ingesteld, elk op hun eigen regel." +hiddenTags: "Verborgen hashtags" +hiddenTagsDescription: "Selecteer tags die niet worden weergegeven in de trends. Meerdere tags kunnen worden geregistreerd, elk op hun eigen regel." +enableStatsForFederatedInstances: "Statistieken van remote servers ontvangen" +limitWidthOfReaction: "Limiteert de maximale breedte van reacties en geef ze verkleind weer" +audio: "Audio" +audioFiles: "Audio" +archived: "Gearchiveerd" +unarchive: "Dearchiveren" +lookupConfirm: "Weet je zeker dat je dit wil opzoeken?" +openTagPageConfirm: "Wil je deze hashtagpagina openen?" +specifyHost: "Specificeer host" icon: "Avatar" -replies: "Antwoord" +replies: "Antwoorden" renotes: "Herdelen" +followingOrFollower: "Gevolgd of volger" +confirmShowRepliesAll: "Dit is een onomkeerbare operatie. Weet je zeker dat reacties op anderen van iedereen die je volgt, wil weergeven in je tijdlijn?" +information: "Over" +_chat: + invitations: "Uitnodigen" + noHistory: "Geen geschiedenis gevonden" + members: "Leden" + home: "Startpagina" + send: "Stuur" _delivery: stop: "Opgeschort" _type: none: "Publiceren" +_role: + priority: "Prioriteit" + _priority: + low: "Lage" + middle: "Medium" + high: "Hoge" +_ffVisibility: + public: "Publiceren" +_ad: + back: "Terug" _email: _follow: title: "volgde jou" _theme: + description: "Beschrijving" keys: mention: "Vermelding" renote: "Herdelen" + divider: "Scheider" _sfx: note: "Notities" notification: "Meldingen" @@ -497,6 +1025,7 @@ _profile: name: "Naam" username: "Gebruikersnaam" _exportOrImport: + clips: "Clip aanmaken" followingList: "Volgend" muteList: "Dempen" blockingList: "Blokkeren" @@ -507,6 +1036,9 @@ _charts: federation: "Federatie" _timelines: home: "Startpagina" +_play: + script: "Script" + summary: "Beschrijving" _pages: blocks: image: "Afbeeldingen" @@ -529,9 +1061,15 @@ _deck: tl: "Tijdlijn" antenna: "Antennes" list: "Lijsten" + channel: "Kanalen" mentions: "Vermeldingen" _webhookSettings: name: "Naam" + active: "Ingeschakeld" +_abuseReport: + _notificationRecipient: + _recipientType: + mail: "Email" _moderationLogTypes: suspend: "Opschorten" resetPassword: "Wachtwoord terugzetten" @@ -540,3 +1078,5 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "Niet gevonden" +_search: + searchScopeAll: "Alle" diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 474e05ba67..44b2cd2704 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -299,8 +299,6 @@ text: "Tekst" next: "Neste" retype: "Gjenta" quoteAttached: "Sitat" -noMessagesYet: "Ingen meldinger ennå" -newMessageExists: "Det er nye meldinger" onlyOneFileCanBeAttached: "Du kan bare legge ved én fil i en melding" invitations: "Inviter" available: "Tilgjengelig" @@ -463,6 +461,12 @@ icon: "Avatar" replies: "Svar" renotes: "Renote" surrender: "Avbryt" +information: "Informasjon" +_chat: + invitations: "Inviter" + members: "Medlemmer" + home: "Hjem" + send: "Send" _delivery: stop: "Suspendert" _initialAccountSetting: @@ -730,3 +734,5 @@ _moderationLogTypes: _remoteLookupErrors: _noSuchObject: title: "Ikke funnet" +_search: + searchScopeAll: "Alle" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 9bd585de86..46f8939137 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -269,7 +269,6 @@ deleteAreYouSure: "Czy na pewno chcesz usunąć „{x}”?" resetAreYouSure: "Czy na pewno chcesz zresetować?" areYouSure: "Na pewno?" saved: "Zapisano" -messaging: "Wiadomości" upload: "Wyślij" keepOriginalUploading: "Zachowaj oryginalny obraz" keepOriginalUploadingDescription: "Zapisuje oryginalnie przesłany obraz w niezmienionej postaci. Jeśli ta opcja jest wyłączona, po przesłaniu zostanie wygenerowana wersja do wyświetlenia w Internecie." @@ -282,7 +281,6 @@ uploadFromUrlMayTakeTime: "Wysyłanie może chwilę potrwać." explore: "Eksploruj" messageRead: "Przeczytano" noMoreHistory: "Nie ma dalszej historii" -startMessaging: "Rozpocznij czat" nUsersRead: "przeczytano przez {n}" agreeTo: "Wyrażam zgodę na {0}" agree: "Zatwierdź" @@ -466,8 +464,6 @@ retype: "Wprowadź ponownie" noteOf: "Wpisy {user}" quoteAttached: "Zacytowano" quoteQuestion: "Czy na pewno chcesz umieścić cytat?" -noMessagesYet: "Nie napisano jeszcze wiadomości" -newMessageExists: "Masz nową wiadomość" onlyOneFileCanBeAttached: "Możesz załączyć tylko jeden plik do wiadomości" signinRequired: "Proszę się zalogować" invitations: "Zaproś" @@ -1044,6 +1040,14 @@ flip: "Odwróć" lastNDays: "W ciągu ostatnich {n} dni" surrender: "Odrzuć" gameRetry: "Spróbuj ponownie" +postForm: "Formularz tworzenia wpisu" +information: "Informacje" +_chat: + invitations: "Zaproś" + noHistory: "Brak historii" + members: "Członkowie" + home: "Strona główna" + send: "Wyślij" _delivery: stop: "Zawieszono" _type: @@ -1208,7 +1212,6 @@ _theme: header: "Nagłówek" navBg: "Tło paska bocznego" navFg: "Tekst paska bocznego" - navHoverFg: "Tekst paska bocznego (zbliżenie)" navActive: "Tekst paska bocznego (aktywny)" navIndicator: "Wskaźnik paska bocznego" link: "Odnośnik" @@ -1231,11 +1234,8 @@ _theme: buttonHoverBg: "Tło przycisku (po najechaniu)" inputBorder: "Obramowanie pola wejścia" driveFolderBg: "Tło folderu na dysku" - wallpaperOverlay: "Nakładka tapety" badge: "Odznaka" messageBg: "Tło czatu" - accentDarken: "Akcent (ciemniejszy)" - accentLighten: "Akcent (jaśniejszy)" fgHighlighted: "Wyróżniony tekst" _sfx: note: "Wpisy" @@ -1300,6 +1300,7 @@ _permissions: "write:gallery": "Edytuj swoją galerię" "read:gallery-likes": "Wyświetlanie listy polubionych postów w galerii" "write:gallery-likes": "Edytowanie listy polubionych postów w galerii" + "write:chat": "Tworzenie lub usuwanie wiadomości czatu" _auth: shareAccessTitle: "Przyznawanie uprawnień aplikacji" shareAccess: "Czy chcesz autoryzować „{name}” do dostępu do tego konta?" @@ -1583,3 +1584,6 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "Nie znaleziono" +_search: + searchScopeAll: "Wszystkie" + searchScopeLocal: "Lokalne" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index d691022d75..3800de6bcf 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -5,6 +5,7 @@ introMisskey: "Bem-vindo! O Misskey é um serviço de microblog descentralizado poweredByMisskeyDescription: "{name} é uma instância da plataforma de código aberto Misskey." monthAndDay: "{day}/{month}" search: "Pesquisar" +reset: "Redefinir" notifications: "Notificações" username: "Nome de usuário" password: "Senha" @@ -48,6 +49,7 @@ pin: "Fixar no perfil" unpin: "Desafixar do perfil" copyContent: "Copiar conteúdos" copyLink: "Copiar link" +copyRemoteLink: "Copiar endereço remoto" copyLinkRenote: "Copiar o link da repostagem" delete: "Excluir" deleteAndEdit: "Excluir e editar" @@ -287,7 +289,6 @@ deleteAreYouSure: "Deseja excluir \"{x}\"?" resetAreYouSure: "Deseja reiniciar?" areYouSure: "Tem certeza?" saved: "Salvo" -messaging: "Chat" upload: "Fazer upload" keepOriginalUploading: "Manter a imagem original" keepOriginalUploadingDescription: "Ao fazer o upload de uma imagem, ela será mantida em sua versão original. Caso desative esta opção, o navegador irá gerar uma versão da imagem otimizada para publicação na web durante o upload." @@ -300,7 +301,7 @@ uploadFromUrlMayTakeTime: "Pode levar algum tempo para que o upload seja conclu explore: "Explorar" messageRead: "Lida" noMoreHistory: "Não existe histórico anterior" -startMessaging: "Iniciar conversação" +startChat: "Iniciar conversa" nUsersRead: "{n} pessoas leram" agreeTo: "Eu concordo com {0}" agree: "Concordar" @@ -423,6 +424,7 @@ antennaExcludeBots: "Ignorar contas de bot" antennaKeywordsDescription: "Se você separá-lo com um espaço, será uma especificação AND, e se você separá-lo com uma quebra de linha, será uma especificação OR." notifyAntenna: "Notificar novas notas" withFileAntenna: "Apenas notas com arquivos anexados" +excludeNotesInSensitiveChannel: "Excluir notas de canais sensíveis" enableServiceworker: "Ative as notificações push para o seu navegador" antennaUsersDescription: "Especificar nomes de utilizador separados por quebras de linha" caseSensitive: "Maiúsculas e minúsculas" @@ -489,8 +491,6 @@ noteOf: "Publicação de {user}" quoteAttached: "Com citação" quoteQuestion: "Anexar como citação?" attachAsFileQuestion: "O texto na área de transferência é muito longo. Você gostaria de anexá-lo como um arquivo de texto?" -noMessagesYet: "Sem conversas até o momento" -newMessageExists: "Há uma nova mensagem" onlyOneFileCanBeAttached: "Apenas um arquivo pode ser anexado a uma mensagem" signinRequired: "É necessário se inscrever ou fazer login antes de continuar" signinOrContinueOnRemote: "Para continuar, você precisa mover o seu servidor ou entrar/cadastrar-se nesse servidor." @@ -684,14 +684,19 @@ smtpSecure: "Use SSL/TLS implícito para conexões SMTP" smtpSecureInfo: "Desative esta opção ao utilizar STARTTLS." testEmail: "Testar envio de e-mail" wordMute: "Silenciar palavras" +wordMuteDescription: "Minimizar notas que contêm a palavra ou frase especificada. Notas minimizadas são exibidas ao clicá-las." hardWordMute: "Silenciar palavras (esconder posts)" +showMutedWord: "Exibir palavras silenciadas" +hardWordMuteDescription: "Esconder notas que contêm a palavra ou frase especificada. Diferente do silenciamento de palavras, a nota será completamente escondida." regexpError: "Erro na expressão regular" regexpErrorDescription: "Ocorreu um erro na expressão regular na linha {line} da palavra mutada {tab}:" instanceMute: "Instâncias silenciadas" userSaysSomething: "{name} disse algo" +userSaysSomethingAbout: "{name} disse algo sobre \"{word}\"" makeActive: "Ativar" display: "Visualizar" copy: "Copiar" +copiedToClipboard: "Copiado à área de transferência" metrics: "Métricas" overview: "Visão geral" logs: "Logs" @@ -974,6 +979,7 @@ document: "Documentação" numberOfPageCache: "Número de cache de página" numberOfPageCacheDescription: "Aumentar isso melhora a conveniência, mas também resulta em maior carga e uso de memória." logoutConfirm: "Gostaria de encerrar a sessão?" +logoutWillClearClientData: "Sair irá remover as configurações do cliente do navegador. Para redefinir as configurações ao entrar, você deve habilitar o backup automático de configurações." lastActiveDate: "Última data de uso" statusbar: "Barra de status" pleaseSelect: "Por favor, selecione." @@ -1301,6 +1307,137 @@ lockdown: "Lockdown" pleaseSelectAccount: "Selecione uma conta" availableRoles: "Cargos disponíveis" acknowledgeNotesAndEnable: "Ative após compreender as precauções." +federationSpecified: "Esse servidor opera com uma lista branca de federação. Interagir com servidores diferentes daqueles designados pela administração não é permitido." +federationDisabled: "Federação está desabilitada nesse servidor. Você não pode interagir com usuários de outros servidores." +confirmOnReact: "Confirmar ao reagir" +reactAreYouSure: "Você deseja adicionar uma reação \"{emoji}\"?" +markAsSensitiveConfirm: "Você deseja definir essa mídia como sensível?" +unmarkAsSensitiveConfirm: "Você deseja remover a definição dessa mídia como sensível?" +preferences: "Preferências" +accessibility: "Acessibilidade" +preferencesProfile: "Perfil de preferências" +copyPreferenceId: "Copiar ID de preferências" +resetToDefaultValue: "Reverter ao padrão" +overrideByAccount: "Sobrescrever pela conta" +untitled: "Sem título" +noName: "Sem nome" +skip: "Pular" +restore: "Redefinir" +syncBetweenDevices: "Sincronizar entre dispositivos" +preferenceSyncConflictTitle: "O valor configurado já existe no servidor." +preferenceSyncConflictText: "As preferências com a sincronização ativada irão salvar os seus valores no servidor. Porém, já existem valores no servidor. Qual conjunto de valores você deseja sobrescrever?" +preferenceSyncConflictChoiceServer: "Valor configurado no servidor" +preferenceSyncConflictChoiceDevice: "Valor configurado no dispositivo" +preferenceSyncConflictChoiceCancel: "Cancelar a habilitação de sincronização" +paste: "Colar" +emojiPalette: "Paleta de emojis" +postForm: "Campo de postagem" +textCount: "Contagem de caracteres" +information: "Informações" +chat: "Conversas" +migrateOldSettings: "Migrar configurações antigas de cliente" +migrateOldSettings_description: "Isso deve ser feito automaticamente. Caso o processo de migração tenha falhado, você pode acioná-lo manualmente. As informações atuais de migração serão substituídas." +compress: "Comprimir" +right: "Direita" +bottom: "Inferior" +top: "Superior" +embed: "Embed" +settingsMigrating: "Configurações estão sendo migradas, aguarde... (Você pode migrar manualmente em Configurações→Outros→Migrar configurações antigas de cliente)" +readonly: "Ler apenas" +goToDeck: "Voltar ao Deck" +federationJobs: "Tarefas de Federação" +_chat: + noMessagesYet: "Ainda não há mensagens" + newMessage: "Nova mensagem" + individualChat: "Conversa Particular" + individualChat_description: "Ter uma conversa particular com outra pessoa." + roomChat: "Conversa de Grupo" + roomChat_description: "Uma sala de conversas com várias pessoas. Você pode adicionar pessoas que não permitem conversas privadas se elas aceitarem o convite." + createRoom: "Criar Sala" + inviteUserToChat: "Convide usuários para começar a conversar" + yourRooms: "Salas criadas" + joiningRooms: "Salas ingressadas" + invitations: "Convidar" + noInvitations: "Sem convites" + history: "Histórico" + noHistory: "Ainda não há histórico" + noRooms: "Nenhuma sala encontrada" + inviteUser: "Convidar Usuários" + sentInvitations: "Convites Enviados" + join: "Entrar" + ignore: "Ignorar" + leave: "Deixar sala" + members: "Membros" + searchMessages: "Pesquisar mensagens" + home: "Início" + send: "Enviar" + newline: "Nova linha" + muteThisRoom: "Silenciar sala" + deleteRoom: "Excluir sala" + chatNotAvailableForThisAccountOrServer: "Conversas não estão habilitadas nesse servidor ou para essa conta." + chatIsReadOnlyForThisAccountOrServer: "Conversas são apenas para leitura nesse servidor ou para essa conta. Não é possível escrever novas mensagens ou criar/ingressar novas conversas." + chatNotAvailableInOtherAccount: "A função de conversas está desabilitadas para o outro usuário." + cannotChatWithTheUser: "Não é possível conversar com esse usuário." + cannotChatWithTheUser_description: "Conversas estão indisponíveis ou o outro usuário não as habilitou." + chatWithThisUser: "Conversar com usuário" + thisUserAllowsChatOnlyFromFollowers: "Esse usuário aceita conversar apenas com seguidores." + thisUserAllowsChatOnlyFromFollowing: "Esse usuário aceita conversar apenas com quem segue." + thisUserAllowsChatOnlyFromMutualFollowing: "Esse usuário aceita conversar apenas com seguidores mútuos." + thisUserNotAllowedChatAnyone: "Esse usuário não aceita conversar com ninguém." + chatAllowedUsers: "Com quem permitir conversas" + chatAllowedUsers_note: "Você pode conversar com qualquer um com quem tenha iniciado uma conversa independente dessa configuração." + _chatAllowedUsers: + everyone: "Todos" + followers: "Seus seguidores" + following: "Quem você segue" + mutual: "Seguidores mútuos" + none: "Ninguém" +_emojiPalette: + palettes: "Paleta" + enableSyncBetweenDevicesForPalettes: "Sincronizar paleta entre dispositivos" + paletteForMain: "Paleta principal" + paletteForReaction: "Paleta de reações" +_settings: + driveBanner: "Você consegue administrar e configurar o drive, conferir o seu uso e configurar as opções de envio de arquivos." + pluginBanner: "Você pode ampliar as funções do cliente com plugins. Você pode instalar plugins, configurar e administrar individualmente." + notificationsBanner: "Você pode configurar os tipos e intervalo das notificações do servidor, além de notificações push." + api: "API" + webhook: "Webhook" + serviceConnection: "Integração de serviço" + serviceConnectionBanner: "Administre e configure tokens de acesso e webhooks para interagir com aplicações e serviços externos." + accountData: "Dados da conta" + accountDataBanner: "Exportar e importar dados da conta." + muteAndBlockBanner: "Você pode configurar meios para esconder conteúdo e restringir ações de certos usuários." + accessibilityBanner: "Você pode personalizar o visual e comportamento do cliente, além de configurar modos de otimizar o uso." + privacyBanner: "Você pode configurar a privacidade da conta por meio da visibilidade do conteúdo, capacidade de descoberta e aprovação manual de seguidores." + securityBanner: "Você pode configurar a segurança da conta em ajustes como senha, meios de entrada, aplicativos de autenticação e chaves de acesso." + preferencesBanner: "Você pode configurar o comportamento geral do cliente segundo as suas preferências." + appearanceBanner: "Você pode configurar a aparência do cliente e ajustes de tela segundo as suas preferências." + soundsBanner: "Você pode configurar a reprodução de sons no cliente." + timelineAndNote: "Notas e linha do tempo" + makeEveryTextElementsSelectable: "Tornar todos os elementos de texto selecionáveis" + makeEveryTextElementsSelectable_description: "Habilitar isso pode reduzir a usabilidade em algumas situações" + useStickyIcons: "Fazer ícones acompanharem a rolagem da tela" + showNavbarSubButtons: "Mostrar sub-botões na barra de navegação" + ifOn: "Quando ligado" + ifOff: "Quando desligado" + enableSyncThemesBetweenDevices: "Sincronizar temas instalados entre dispositivos" + _chat: + showSenderName: "Exibir nome de usuário do remetente" + sendOnEnter: "Pressionar Enter para enviar" +_preferencesProfile: + profileName: "Nome do perfil" + profileNameDescription: "Defina o nome que identifica esse dispositivo." + profileNameDescription2: "Exemplo: \"Computador Principal\", \"Celular\"" +_preferencesBackup: + autoBackup: "Backup automático" + restoreFromBackup: "Restaurar backup" + noBackupsFoundTitle: "Nenhum backup encontrado" + noBackupsFoundDescription: "Nenhum backup automático foi encontrado. Se você salvou um arquivo de backup manualmente, você pode importá-lo e restaurá-lo." + selectBackupToRestore: "Selecionar um backup para restaurar" + youNeedToNameYourProfileToEnableAutoBackup: "Um nome de perfil deve ser definido para habilitar o backup automático." + autoPreferencesBackupIsNotEnabledForThisDevice: "Backup automático de configurações não está habilitado no dispositivo." + backupFound: "Backup de configurações encontrado" _accountSettings: requireSigninToViewContents: "Exigir cadastro para ver o conteúdo" requireSigninToViewContentsDescription1: "Exigir cadastro para ver todas as notas e outro conteúdo que você criou. Isso previne 'crawlers' de coletar os seus dados." @@ -1311,6 +1448,7 @@ _accountSettings: makeNotesHiddenBefore: "Tornar notas passadas privadas" makeNotesHiddenBeforeDescription: "Com essa função ativada, apenas você poderá ver as notas anteriores à data e hora marcadas. Se isso for desativado, o status de publicação da nota será reestabelecido." mayNotEffectForFederatedNotes: "Notas federadas a servidores remotos podem não ser afetadas." + mayNotEffectSomeSituations: "Essas restrições são simplificadas. Elas podem não ser aplicadas em algumas situações, como ao visualizar num servidor remoto ou durante a moderação." notesHavePassedSpecifiedPeriod: "Notas que duraram um tempo específico." notesOlderThanSpecifiedDateAndTime: "Notas antes do tempo específico." _abuseUserReport: @@ -1756,6 +1894,8 @@ _role: descriptionOfIsExplorable: "Ao ativar, a lista de membros será pública na seção 'Explorar' e a linha do tempo do cargo ficará disponível." displayOrder: "Ordenação" descriptionOfDisplayOrder: "Quanto maior o número, maior a posição de destaque na interface do usuário." + preserveAssignmentOnMoveAccount: "Preservar a associação de cargos durante a migração" + preserveAssignmentOnMoveAccount_description: "Quando ligado, esse cargo será encaminhado para a conta final quando houver migração de um usuário." canEditMembersByModerator: "Permitir a edição de membros deste cargo por moderadores" descriptionOfCanEditMembersByModerator: "Quando ativado, os moderadores também poderão atribuir/remover usuários deste papel, além dos administradores. Quando desativado, apenas os administradores poderão fazê-lo." priority: "Prioridade" @@ -1796,6 +1936,7 @@ _role: canImportFollowing: "Permitir importação de usuários seguidos" canImportMuting: "Permitir importação de silenciamentos" canImportUserLists: "Permitir importação de listas" + chatAvailability: "Permitir Conversas" _condition: roleAssignedTo: "Atribuído a cargos manuais" isLocal: "Usuário local" @@ -1959,6 +2100,7 @@ _theme: installed: "{name} foi instalado" installedThemes: "Temas instalados" builtinThemes: "Temas nativos" + instanceTheme: "Tema do servidor" alreadyInstalled: "Esse tema já foi instalado" invalid: "O formato desse tema é invalido" make: "Fazer um tema" @@ -1991,7 +2133,6 @@ _theme: header: "Cabeçalho" navBg: "Plano de fundo da barra lateral" navFg: "Texto da barra lateral" - navHoverFg: "Texto da coluna lateral (Selecionado)" navActive: "Texto da coluna lateral (Ativa)" navIndicator: "Indicador da coluna lateral" link: "Link" @@ -2014,17 +2155,15 @@ _theme: buttonHoverBg: "Plano de fundo de botão (Selecionado)" inputBorder: "Borda de campo digitável" driveFolderBg: "Plano de fundo da pasta no Drive" - wallpaperOverlay: "Sobreposição do papel de parede." badge: "Emblema" messageBg: "Plano de fundo do chat" - accentDarken: "Cor de destaque (Escurecida)" - accentLighten: "Cor de destaque (Esclarecida)" fgHighlighted: "Texto Destacado" _sfx: note: "Posts" noteMy: "Própria nota" notification: "Notificações" reaction: "Ao selecionar uma reação" + chatMessage: "Mensagens em Conversas" _soundSettings: driveFile: "Usar um arquivo de áudio do Drive." driveFileWarn: "Selecione um arquivo de áudio do Drive." @@ -2171,6 +2310,8 @@ _permissions: "read:clip-favorite": "Ver Clipes favoritados" "read:federation": "Ver dados de federação" "write:report-abuse": "Reportar violação" + "write:chat": "Compor ou editar mensagens de chat" + "read:chat": "Navegar Conversas" _auth: shareAccessTitle: "Conceder permissões do aplicativo" shareAccess: "Você gostaria de autorizar \"{name}\" para acessar essa conta?" @@ -2229,6 +2370,7 @@ _widgets: chooseList: "Selecione uma lista" clicker: "Clicker" birthdayFollowings: "Usuários de aniversário hoje" + chat: "Conversas" _cw: hide: "Esconder" show: "Carregar mais" @@ -2419,6 +2561,7 @@ _notification: newNote: "Nova nota" unreadAntennaNote: "Antena {name}" roleAssigned: "Cargo dado" + chatRoomInvitationReceived: "Você foi convidado para uma conversa" emptyPushNotificationMessage: "As notificações de alerta foram atualizadas" achievementEarned: "Conquista desbloqueada" testNotification: "Notificação teste" @@ -2432,6 +2575,8 @@ _notification: flushNotification: "Limpar notificações" exportOfXCompleted: "Exportação de {x} foi concluída" login: "Alguém entrou na conta" + createToken: "Uma token de acesso foi criada" + createTokenDescription: "Se você não faz ideia, exclua o token de acesso através de \"{text}\"." _types: all: "Todas" note: "Novas notas" @@ -2445,9 +2590,11 @@ _notification: receiveFollowRequest: "Recebeu pedidos de seguidor" followRequestAccepted: "Aceitou pedidos de seguidor" roleAssigned: "Cargo dado" + chatRoomInvitationReceived: "Convite de conversa recebido" achievementEarned: "Conquista desbloqueada" exportCompleted: "A exportação foi concluída" login: "Iniciar sessão" + createToken: "Criar token de acesso" test: "Notificação teste" app: "Notificações de aplicativos conectados" _actions: @@ -2457,6 +2604,9 @@ _notification: _deck: alwaysShowMainColumn: "Sempre mostrar a coluna principal" columnAlign: "Alinhar colunas" + columnGap: "Margem entre colunas" + deckMenuPosition: "Posição do menu do deck" + navbarPosition: "Posição da barra de navegação" addColumn: "Adicionar coluna" newNoteNotificationSettings: "Opções de notificação para novas notas" configureColumn: "Configurar coluna" @@ -2475,6 +2625,7 @@ _deck: useSimpleUiForNonRootPages: "Usar UI simples para páginas navegadas" usedAsMinWidthWhenFlexible: "A largura mínima será usada para isso quando o \"Ajuste automático da largura\" estiver ativado" flexible: "Ajuste automático da largura" + enableSyncBetweenDevicesForProfiles: "Habilitar sincronização das informações do perfil entre dispositivos" _columns: main: "Principal" widgets: "Widgets" @@ -2486,6 +2637,7 @@ _deck: mentions: "Menções" direct: "Notas diretas" roleTimeline: "Linha do tempo do cargo" + chat: "Conversas" _dialog: charactersExceeded: "Você excedeu o limite de caracteres! Atualmente em {current} de {max}." charactersBelow: "Você está abaixo do limite mínimo de caracteres! Atualmente em {current} of {min}." @@ -2582,6 +2734,8 @@ _moderationLogTypes: deletePage: "Remover página" deleteFlash: "Remover Play" deleteGalleryPost: "Remover a publicação da galeria" + deleteChatRoom: "Sala de Conversas Excluída" + updateProxyAccountDescription: "Atualizar descrição da conta de proxy" _fileViewer: title: "Detalhes do arquivo" type: "Tipo de arquivo" @@ -2595,10 +2749,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Tenha certeza de que o distribuidor desse recurso é confiável antes da instalação." _plugin: title: "Deseja instalar esse plugin?" - metaTitle: "Informações do plugin" _theme: title: "Deseja instalar esse tema?" - metaTitle: "Informações do tema" _meta: base: "Paleta de cores base" _vendorInfo: @@ -2718,6 +2870,66 @@ _contextMenu: app: "Aplicativo" appWithShift: "Aplicativo com a tecla shift" native: "Nativo" +_gridComponent: + _error: + requiredValue: "Esse valor é necessário" + columnTypeNotSupport: "Validação de expressões regulares (RegEx) só é permitida em colunas type:text." + patternNotMatch: "Esse valor não se encaixa no padrão de {pattern}" + notUnique: "Valor deve ser único" +_roleSelectDialog: + notSelected: "Não selecionado" +_customEmojisManager: + _gridCommon: + copySelectionRows: "Copiar linhas selecionadas" + copySelectionRanges: "Copiar seleção" + deleteSelectionRows: "Excluir linhas selecionadas" + deleteSelectionRanges: "Excluir valores selecionados" + searchSettings: "Opções de busca" + searchSettingCaption: "Definir critérios detalhados de busca." + searchLimit: "Limite de busca" + sortOrder: "Ordem de classificação" + registrationLogs: "Histórico de registros" + registrationLogsCaption: "Atualizações e remoções de emoji serão gravadas no histórico. Atualizar, remover, mover a uma nova página ou recarregar limpará o histórico" + alertEmojisRegisterFailedDescription: "Não foi possível atualizar ou remover emojis. Por favor, confira o histórico de registro para mais detalhes." + _logs: + showSuccessLogSwitch: "Exibir sucessos no histórico" + failureLogNothing: "Não há registro de falhas." + logNothing: "Não há registros." + _remote: + selectionRowDetail: "Detalhes da linha selecionada" + importSelectionRows: "Importar linhas selecionadas" + importSelectionRangesRows: "Importar linhas no intervalo" + importEmojisButton: "Importar Emojis selecionados" + confirmImportEmojisTitle: "Importar Emojis" + confirmImportEmojisDescription: "Importar {count} Emoji(s) recebidos de um servidor remoto. Por favor, preste atenção na licença do Emoji. Tem certeza que deseja continuar?" + _local: + tabTitleList: "Emojis registrados" + tabTitleRegister: "Registro de Emoji" + _list: + emojisNothing: "Não há Emojis registrados." + markAsDeleteTargetRows: "Marcar linhas selecionadas para remoção" + markAsDeleteTargetRanges: "Marcar linhas no intervalo para remoção" + alertUpdateEmojisNothingDescription: "Não há Emojis atualizados." + alertDeleteEmojisNothingDescription: "Não há Emojis marcados para remoção." + confirmMovePage: "Deseja mudar de página?" + confirmChangeView: "Deseja mudar de seção?" + confirmUpdateEmojisDescription: "Atualizando {count} Emoji(s). Deseja continuar?" + confirmDeleteEmojisDescription: "Removendo {count} Emoji(s) marcado(s). Deseja continuar?" + confirmResetDescription: "Todas as mudanças serão redefinidas." + confirmMovePageDesciption: "Mudanças foram feitas nos Emojis dessa página. Se você sair sem salvar, todas serão descartadas." + dialogSelectRoleTitle: "Buscar por cargo que pode usar esse Emoji" + _register: + uploadSettingTitle: "Configurações de envio" + uploadSettingDescription: "Nessa tela, você pode configurar o comportamento ao enviar Emojis." + directoryToCategoryLabel: "Transformar as pastas em categorias" + directoryToCategoryCaption: "Quando você arrastar um diretório, converter o caminho das pastas no campo \"categoria\"." + emojiInputAreaCaption: "Selecione Emojis que você deseja registrar utilizando um dos métodos." + emojiInputAreaList1: "Arraste arquivos de imagem ou diretórios dentro desse quadro" + emojiInputAreaList2: "Clique nesse link para abrir a seleção de arquivos" + emojiInputAreaList3: "Clique nesse link para selecionar do drive" + confirmRegisterEmojisDescription: "Registrando os Emojis da lista como novos Emojis personalizados. Deseja continuar? (Para evitar sobrecarga, apenas {count} Emoji(s) podem ser registrados em uma única operação)" + confirmClearEmojisDescription: "Descartando edições e limpando Emojis da lista. Deseja continuar?" + confirmUploadEmojisDescription: "Enviando {count} arquivo(s) arrastados ao drive. Deseja continuar?" _embedCodeGen: title: "Personalizar código do embed" header: "Exibir cabeçalho" @@ -2754,8 +2966,39 @@ _remoteLookupErrors: _responseInvalid: title: "Resposta inválida" description: "Foi possível comunicar com o servidor, porém os dados obtidos foram incorretos." - _responseInvalidIdHostNotMatch: - description: "O domínio do endereço inserido difere do domínio do endereço final. Se você estiver pesquisando por um servidor de terceiros, tente buscar novamente com um endereço que pode ser obtido através do servidor original." _noSuchObject: title: "Não encontrado" description: "O recurso solicitado não foi encontrado, confira o endereço." +_captcha: + verify: "Por favor, verifique o CAPTCHA" + testSiteKeyMessage: "Você pode conferir a prévia inserindo valores de teste para o site e chaves secretas.\nVeja a página seguinte para mais detalhes." + _error: + _requestFailed: + title: "O pedido do CAPTCHA falhou" + text: "Por favor, tente novamente ou verifique as configurações." + _verificationFailed: + title: "A validação do CAPTCHA falhou" + text: "Por favor, verifique se as configurações estão corretas." + _unknown: + title: "Erro CAPTCHA" + text: "Houve um erro inexperado." +_bootErrors: + title: "Falha ao carregar" + serverError: "Se o problema persistir após esperar um momento e recarregar, contate a administração da instância com o seguinte ID de erro." + solution: "O seguinte pode resolver o problema." + solution1: "Atualize seu navegador e sistema operacional para a última versão." + solution2: "Desative o bloqueador de anúncios" + solution3: "Limpe o cache do navegador" + solution4: "Defina dom.webaudio.enabled como verdadeiro no Navegador Tor" + otherOption: "Outras opções" + otherOption1: "Excluir ajustes de cliente e cache" + otherOption2: "Iniciar o cliente simples" + otherOption3: "Iniciar ferramenta de reparo" +_search: + searchScopeAll: "Todos" + searchScopeLocal: "Local" + searchScopeServer: "Servidor específico" + searchScopeUser: "Usuário específico" + pleaseEnterServerHost: "Insira o endereço do servidor" + pleaseSelectUser: "Selecione um usuário" + serverHostPlaceholder: "Exemplo: misskey.example.com" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 07f4c98d96..ee2b17cfc5 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -1,15 +1,19 @@ --- _lang_: "Română" headlineMisskey: "O rețea conectată prin note" -introMisskey: "Bine ai venit! Misskey este un serviciu de microblogging open source și decentralizat.\nCreează \"note\" cu care să îți poți împărți gândurile cu oricine din jurul tău. 📡\nCu \"reacții\" îți poți expirma rapid părerea despre notele oricui. 👍\nHai să explorăm o lume nouă! 🚀" +introMisskey: "Bine ai venit! Misskey este un serviciu de microblogging open source și decentralizat.\nCreează \"note\" cu care să îți poți împărțasi gândurile cu oricine din jurul tău. 📡\nCu \"reacții\" îți poți exprima rapid părerea despre notele oricui. 👍\nHai să explorăm o lume nouă! 🚀" poweredByMisskeyDescription: "{name} este unul dintre serviciile care se folosește de platforma open source Misskey." monthAndDay: "{day}/{month}" search: "Caută" +reset: "Resetează." notifications: "Notificări" username: "Nume de utilizator" password: "Parolă" +initialPasswordForSetup: "Parola pentru a începe configurarea inițială." +initialPasswordIsIncorrect: "Parola inițială este incorectă." +initialPasswordForSetupDescription: "Dacă ai instalat singur Misskey, utilizează parola pe care ai introdus-o în fișierul de configurare.\n\nDacă utilizezi un serviciu de găzduire(hosting) precum Misskey, te rugăm să utilizezi parola furnizată.\n\nDacă nu ai setat o parolă, las-o necompletată și mergi mai departe." forgotPassword: "Am uitat parola" -fetchingAsApObject: "Se aduce din Fediverse..." +fetchingAsApObject: "Se preia din Fediverse..." ok: "OK" gotIt: "Am înțeles!" cancel: "Anulează" @@ -45,26 +49,28 @@ pin: "Fixează pe profil" unpin: "Anulati fixare" copyContent: "Copiază conținutul" copyLink: "Copiază link-ul" -copyLinkRenote: "Copiază linkul pentru renote" +copyRemoteLink: "Copiază sursa externă." +copyLinkRenote: "Copiază linkul pentru re-notare" delete: "Şterge" deleteAndEdit: "Șterge și editează" -deleteAndEditConfirm: "Ești sigur că vrei să ștergi această notă și să o editezi? Vei pierde reacțiile, re-notele și răspunsurile acesteia." +deleteAndEditConfirm: "Ești sigur(ă) că vrei să ștergi această notă și să o editezi? Vei pierde reacțiile, Re-Notele și răspunsurile acestora." addToList: "Adaugă în listă" addToAntenna: "Adaugă la antenă" sendMessage: "Trimite un mesaj" copyRSS: "Copiază RSS" copyUsername: "Copiază numele de utilizator" -copyUserId: "Copiază numele de utilizator" +copyUserId: "Copiază ID-ul de utilizator" copyNoteId: "Copiază ID-ul notiței" copyFileId: "Copiază ID-ul fișierului" copyFolderId: "Copiază ID-ul folderului" -copyProfileUrl: "Copiază URL profil" +copyProfileUrl: "Copiază URL-ul profilului " searchUser: "Caută un utilizator" +searchThisUsersNotes: "Caută în notele acestui utilizator." reply: "Răspunde" loadMore: "Incarcă mai mult" showMore: "Arată mai mult" showLess: "Închide" -youGotNewFollower: "te-a urmărit" +youGotNewFollower: "Te-a urmărit" receiveFollowRequest: "Cerere de urmărire primită" followRequestAccepted: "Cerere de urmărire acceptată" mention: "Mențiune" @@ -75,21 +81,21 @@ import: "Importă" export: "Exportă" files: "Fișiere" download: "Descarcă" -driveFileDeleteConfirm: "Ești sigur ca vrei să ștergi fișierul \"{name}\"? Notele atașate fișierului vor fi șterse și ele." -unfollowConfirm: "Ești sigur ca vrei să nu mai urmărești pe {name}?" +driveFileDeleteConfirm: "Ești sigur(ă) că vrei să ștergi fișierul \"{name}\"? Notele atașate fișierului vor fi și ele șterse." +unfollowConfirm: "Ești sigur(ă) că vrei să nu mai urmărești pe {name}?" exportRequested: "Ai cerut un export. S-ar putea să ia un pic. Va fi adăugat in Drive-ul tău odată completat." importRequested: "Ai cerut un import. S-ar putea să ia un pic." lists: "Liste" -noLists: "Nu ai nici o listă" +noLists: "Nu ai nicio listă" note: "Notă" notes: "Note" -following: "Urmărești" +following: "Îl urmărești" followers: "Urmăritori" followsYou: "Te urmărește" createList: "Creează listă" manageLists: "Gestionează listele" error: "Eroare" -somethingHappened: "A survenit o eroare" +somethingHappened: "A apărut o eroare" retry: "Reîncearcă" pageLoadError: "A apărut o eroare la încărcarea paginii." pageLoadErrorDescription: "De obicei asta este cauzat de o eroare de rețea sau cache-ul browser-ului. Încearcă să cureți cache-ul și apoi să încerci din nou puțin mai târziu." @@ -99,20 +105,23 @@ enterListName: "Introdu un nume pentru listă" privacy: "Confidenţialitate" makeFollowManuallyApprove: "Fă cererile de urmărire să necesite aprobare" defaultNoteVisibility: "Vizibilitate implicită" -follow: "Urmărești" +follow: "Urmărește" followRequest: "Trimite cerere de urmărire" followRequests: "Cereri de urmărire" unfollow: "Nu mai urmări" followRequestPending: "Cerere de urmărire în așteptare" enterEmoji: "Introdu un emoji" -renote: "Re-notează" -unrenote: "Ia înapoi re-nota" +renote: "Re-Notează" +unrenote: "Anulează re-nota" renoted: "Re-notat." +renotedToX: "Re-notă către {name}." cantRenote: "Această postare nu poate fi re-notată." cantReRenote: "O re-notă nu poate fi re-notată." quote: "Citează" -inChannelRenote: "Renotează în canal" +inChannelRenote: "Re-Notează în canal" inChannelQuote: "Citează în canal" +renoteToChannel: "Re-notă către alte canale." +renoteToOtherChannel: "Re-notă către alte canale." pinnedNote: "Notă fixată" pinned: "Fixat pe profil" you: "Tu" @@ -121,42 +130,52 @@ sensitive: "NSFW" add: "Adaugă" reaction: "Reacție" reactions: "Reacție" +emojiPicker: "Selectator de emoji" +pinnedEmojisForReactionSettingDescription: "Poți seta emoji-urile să fie fixate atunci când reacționați." +pinnedEmojisSettingDescription: "Poți seta emoji-urile să fie fixate și afișate la introducerea emoji-urilor." +emojiPickerDisplay: "Meniu de selectare ale reacțiilor." +overwriteFromPinnedEmojisForReaction: "Ignoră din setările de reacție." +overwriteFromPinnedEmojis: "Ignoră din setările generale." reactionSettingDescription2: "Trage pentru a rearanja, apasă pe \"+\" pentru a adăuga." rememberNoteVisibility: "Amintește setarea de vizibilitate a notelor" attachCancel: "Înlătură atașament" +deleteFile: "Șterge fișierul." markAsSensitive: "Marchează ca NSFW" unmarkAsSensitive: "Demarchează ca NSFW" -enterFileName: "Introduceţi numele fişierului" +enterFileName: "Introdu numele fişierului" mute: "Amuțește" unmute: "Înlătură amuțirea" -renoteMute: "Renotări pe modul silențios" +renoteMute: "Re-notări pe modul silențios" renoteUnmute: "Scoate renotările de pe modul silențios" block: "Blochează" unblock: "Deblochează" suspend: "Suspendă" unsuspend: "Anulează suspendare" -blockConfirm: "Ești sigur că vrei să blochezi acest cont?" -unblockConfirm: "Ești sigur ca vrei să deblochezi acest cont?" -suspendConfirm: "Ești sigur ca vrei să suspendezi acest cont?" -unsuspendConfirm: "Ești sigur ca vrei să nu mai suspendezi acest cont?" +blockConfirm: "Ești sigur(ă) că vrei să blochezi acest cont?" +unblockConfirm: "Ești sigur(ă) că vrei să deblochezi acest cont?" +suspendConfirm: "Ești sigur(ă) că vrei să suspendezi acest cont?" +unsuspendConfirm: "Ești sigur că vrei să nu mai suspendezi acest cont?" selectList: "Selectează o listă" -editList: "Editați lista" -selectChannel: "Selectaţi canalul" +editList: "Editează lista" +selectChannel: "Selectează canalul" selectAntenna: "Selectează o antenă" editAntenna: "Editează antena" -selectWidget: "Selectați un widget" +createAntenna: "Creează o antenă." +selectWidget: "Alege un widget" editWidgets: "Editează widget-urile" editWidgetsExit: "Terminat" -customEmojis: "Emoji personalizat" +customEmojis: "Emoji personalizate" emoji: "Emoji" emojis: "Emoji-uri" emojiName: "Numele emoji-ului" emojiUrl: "URL-ul emoji-ului" addEmoji: "Adaugă un emoji" settingGuide: "Setări recomandate" -cacheRemoteFiles: "Ține fișierele externe in cache" -cacheRemoteFilesDescription: "Când această setare este dezactivată, fișierele externe sunt încărcate direct din instanța externă. Dezactivarea va scădea utilizarea spațiului de stocare, dar va crește traficul, deoarece thumbnail-urile nu vor fi generate." +cacheRemoteFiles: "Reţine fișierele externe in memoria cache." +cacheRemoteFilesDescription: "Când această setare este dezactivată, fișierele externe sunt încărcate direct din instanța externă. Dezactivarea va scădea utilizarea spațiului de stocare, dar va crește traficul, deoarece miniaturile nu vor fi generate." youCanCleanRemoteFilesCache: "Poți goli cache-ul prin a apăsa pe butonul de 🗑️ din fereastra de gestionare a fișierelor." +cacheRemoteSensitiveFiles: "Memorează în cache fișierele sensibile la distanță." +cacheRemoteSensitiveFilesDescription: "Dacă dezactivezi această setare, fișierele sensibile externe vor fi conectate direct și nu stocate în cache." flagAsBot: "Marchează acest cont ca bot" flagAsBotDescription: "Activează această opțiune dacă acest cont este controlat de un program. Daca e activată, aceasta va juca rolul unui indicator pentru dezvoltatori pentru a preveni interacțiunea în lanțuri infinite cu ceilalți boți și ajustează sistemele interne al Misskey pentru a trata acest cont drept un bot." flagAsCat: "Marchează acest cont ca pisică" @@ -165,18 +184,24 @@ flagShowTimelineReplies: "Arată răspunsurile în cronologie" flagShowTimelineRepliesDescription: "Dacă e activată vor fi arătate în cronologie răspunsurile utilizatorilor către alte notele altor utilizatori." autoAcceptFollowed: "Aprobă automat cererile de urmărire de la utilizatorii pe care îi urmărești" addAccount: "Adaugă un cont" +reloadAccountsList: "Reîncarcă informațiile din lista de conturi" loginFailed: "Autentificare eșuată" showOnRemote: "Vezi mai multe pe instanța externă" +continueOnRemote: "Continuă de pe sursa externa." +chooseServerOnMisskeyHub: "Selectează un server din Hub-ul Misskey." +specifyServerHost: "Specifică un server gazdă(host)." +inputHostName: "Introdu numele gazdă(hostname)." general: "General" wallpaper: "Imagine de fundal" -setWallpaper: "Setați imaginea de fundal" +setWallpaper: "Setează imaginea de fundal" removeWallpaper: "Șterge imagine de fundal" searchWith: "Caută: {q}" youHaveNoLists: "Nu ai nici o listă" -followConfirm: "Ești sigur ca vrei să urmărești pe {name}?" +followConfirm: "Ești sigur(ă) că vrei să urmărești pe {name}?" proxyAccount: "Cont proxy" proxyAccountDescription: "Un cont proxy este un cont care se comportă ca un urmăritor extern pentru utilizatorii puși sub anumite condiții. De exemplu, când un cineva adaugă un utilizator extern intr-o listă, activitatea utilizatorului extern nu va fi adusă în instanță daca nici un utilizator local nu urmărește acel utilizator, așa că în schimb contul proxy îl va urmări." host: "Gazdă" +selectSelf: "Selectează-te pe tine însuți." selectUser: "Selectează un utilizator" recipient: "Destinatar" annotation: "Adnotări" @@ -191,6 +216,8 @@ perHour: "Pe oră" perDay: "Pe zi" stopActivityDelivery: "Nu mai trimite activități" blockThisInstance: "Blochează această instanță" +silenceThisInstance: "Ascunde acest server." +mediaSilenceThisInstance: "Ascunde conținutul media din acest server." operations: "Operațiuni" software: "Software" version: "Versiune" @@ -204,24 +231,31 @@ disk: "Disk" instanceInfo: "Informații despre instanță" statistics: "Statistici" clearQueue: "Șterge coada" -clearQueueConfirmTitle: "Ești sigur că vrei să cureți coada?" +clearQueueConfirmTitle: "Ești sigur(ă) că vrei să cureți coada?" clearQueueConfirmText: "Orice notă rămasă în coadă nu va fi federată. De obicei această operație nu este necesară." clearCachedFiles: "Golește cache-ul" -clearCachedFilesConfirm: "Ești sigur că vrei să ștergi toate fișierele externe din cache?" +clearCachedFilesConfirm: "Ești sigur(ă) că vrei să ștergi toate fișierele externe din cache?" blockedInstances: "Instanțe blocate" -blockedInstancesDescription: "Scrie hostname-urile instanțelor pe care dorești să le blochezi. Instanțele listate nu vor mai putea să comunice cu această instanță." +blockedInstancesDescription: "Scrie numele gazdă(hostname) ale serverelor pe care dorești să le blochezi. Serverele listate nu vor mai putea să comunice cu acest server." +silencedInstances: "Servere ascunse." +silencedInstancesDescription: "Listează numele de gazdă(hostname) ale serverelor pe care dorești să le ascunzi, separate printr-o nouă linie de spațiere. Toate conturile care aparțin serverelor enumerate vor fi tratate ca fiind ascunse și pot face doar solicitări de urmărire și nu pot menționa conturi locale dacă nu sunt urmate. Acest lucru nu va afecta serverele blocate." +mediaSilencedInstances: "Servere cu conținutul media ascuns." +mediaSilencedInstancesDescription: "Setați numele de gazdă(hostname-urile) ale serverelor pe care dorești să le ascunzi, separate de o linie noua de spațiere. Orice fișier din conturile de pe un server cu sunet media vor fi tratate ca fiind sensibile și nu vor putea folosi emoji-uri personalizate. Nu are niciun efect asupra serverelor blocate." +federationAllowedHosts: "Servere permise pentru federare" +federationAllowedHostsDescription: "Specifica numele de gazdă ale serverelor pe care dorești să le permiți federarea, separate prin spații noi." muteAndBlock: "Amuțiri și Blocări" mutedUsers: "Utilizatori amuțiți" blockedUsers: "Utilizatori blocați" noUsers: "Niciun utilizator" editProfile: "Editează profilul" -noteDeleteConfirm: "Ești sigur că vrei să ștergi această notă?" +noteDeleteConfirm: "Ești sigur(ă) că vrei să ștergi această notă?" pinLimitExceeded: "Nu poți mai fixa mai multe note" intro: "Misskey s-a instalat! Te rog crează un utilizator admin." done: "Gata" processing: "Se procesează" preview: "Previzualizare" default: "Prestabilit" +defaultValueIs: "Valori implicite: {value}" noCustomEmojis: "Nu e niciun emoji" noJobs: "Nu e niciun job" federating: "Federație" @@ -232,7 +266,7 @@ subscribing: "Abonare" publishing: "Publicare" notResponding: "Nu răspunde" instanceFollowing: "Urmărind în instanță" -instanceFollowers: "Urmăritori ai instanței" +instanceFollowers: "Urmăritori al instanței" instanceUsers: "Utilizatori ai acestei instanțe" changePassword: "Schimbă parolă" security: "Securitate" @@ -250,11 +284,11 @@ announcements: "Anunțuri" imageUrl: "URL-ul imaginii" remove: "Şterge" removed: "Șterș cu succes" -removeAreYouSure: "Ești sigur că vrei să înlături {x}?" -deleteAreYouSure: "Ești sigur că vrei să ștergi {x}?" +removeAreYouSure: "Ești sigur(ă) că vrei să înlături {x}?" +deleteAreYouSure: "Ești sigur(ă) că vrei să ștergi {x}?" resetAreYouSure: "Sigur vrei să resetezi?" +areYouSure: "Ești sigur(ă)?" saved: "Salvat" -messaging: "Chat" upload: "Încarcă" keepOriginalUploading: "Păstrează imaginea originală" keepOriginalUploadingDescription: "Salvează imaginea originala încărcată fără modificări. Dacă e oprită, o versiune pentru afișarea pe web va fi generată la încărcare." @@ -267,9 +301,13 @@ uploadFromUrlMayTakeTime: "S-ar putea să ia puțin până se finalizează înc explore: "Explorează" messageRead: "Citit" noMoreHistory: "Nu există mai mult istoric" -startMessaging: "Începe un chat nou" +startChat: "Pornește chat-ul" nUsersRead: "citit de {n}" agreeTo: "Sunt de acord cu {0}" +agree: "De acord" +agreeBelow: "Sunt de acord cu cele menționate mai jos" +basicNotesBeforeCreateAccount: "Detalii importante" +termsOfService: "Termenii serviciului" start: "Să începem" home: "Acasă" remoteUserCaution: "Deoarece acest utilizator este dintr-o instanță externă, informația afișată poate fi incompletă." @@ -290,21 +328,24 @@ darkThemes: "Teme întunecate" syncDeviceDarkMode: "Sincronizează Modul Întunecat cu setările dispozitivului" drive: "Drive" fileName: "Nume fișier" -selectFile: "Alege un fisier" +selectFile: "Alege un fișier" selectFiles: "Alege fișiere" selectFolder: "Selectează un folder" selectFolders: "Selectează folderele" +fileNotSelected: "Niciun fișier selectat" renameFile: "Redenumește fișier" folderName: "Nume folder" createFolder: "Crează folder" renameFolder: "Redenumește acest folder" deleteFolder: "Șterge acest folder" -addFile: "Adăugați un fișier" +folder: "Folder" +addFile: "Adaugă un fișier" +showFile: "Arata fișierele" emptyDrive: "Drive-ul tău e gol" emptyFolder: "Folder-ul acesta este gol" unableToDelete: "Nu se poate șterge" inputNewFileName: "Introdu un nou nume de fișier" -inputNewDescription: "Introdu o descriere nouă" +inputNewDescription: "Introdu o titrare nouă" inputNewFolderName: "Introdu un nume de folder nou" circularReferenceFolder: "Destinația folderului este un subfolder al folderului pe care dorești să îl muți." hasChildFilesOrFolders: "Acest folder nu este gol, așa că nu poate fi șters." @@ -312,8 +353,9 @@ copyUrl: "Copiază URL" rename: "Redenumește" avatar: "Avatar" banner: "Banner" +displayOfSensitiveMedia: "Afișarea conținutului media sensibil" whenServerDisconnected: "Când pierzi conexiunea cu serverul" -disconnectedFromServer: "Conecțiunea cu serverul a fost pierdută" +disconnectedFromServer: "Conexiunea cu serverul a fost pierdută" reload: "Reîncarcă" doNothing: "Ignoră" reloadConfirm: "Ai dori să reîmprospătezi cronologia?" @@ -349,21 +391,26 @@ bannerUrl: "URL-ul imaginii de banner" backgroundImageUrl: "URL-ul imaginii de fundal" basicInfo: "Informații de bază" pinnedUsers: "Utilizatori fixați" -pinnedUsersDescription: "Scrie utilizatorii, separați prin pauză de rând, care vor fi fixați pe pagina \"Explorează\"." +pinnedUsersDescription: "Scrie utilizatorii, separați prin o linie de rând, care vor fi fixați pe pagina \"Explorează\"." pinnedPages: "Pagini fixate" -pinnedPagesDescription: "Introdu linkurile Paginilor pe care le vrei fixate in vâruful paginii acestei instanțe, separate de pauze de rând." +pinnedPagesDescription: "Introdu linkurile Paginilor pe care le vrei fixate in vârful paginii acestei instanțe, separate de o linie de spațiere." pinnedClipId: "ID-ul clip-ului pe care să îl fixezi" pinnedNotes: "Notă fixată" hcaptcha: "hCaptcha" enableHcaptcha: "Activează hCaptcha" hcaptchaSiteKey: "Site key" hcaptchaSecretKey: "Secret key" +mcaptcha: "mCaptcha" +enableMcaptcha: "Permite mCaptcha" mcaptchaSiteKey: "Site key" mcaptchaSecretKey: "Secret key" +mcaptchaInstanceUrl: "URL-ul serverului mCaptcha" recaptcha: "reCAPTCHA" enableRecaptcha: "Activează reCAPTCHA" recaptchaSiteKey: "Site key" recaptchaSecretKey: "Secret key" +turnstile: "\nTurnstile" +enableTurnstile: "Permite Turnstile" turnstileSiteKey: "Site key" turnstileSecretKey: "Secret key" avoidMultiCaptchaConfirm: "Folosirea mai multor sisteme Captcha poate cauza interferență între acestea. Ai dori să dezactivezi alte sisteme Captcha acum active? Dacă preferi să rămână activate, apasă Anulare." @@ -373,9 +420,11 @@ name: "Nume" antennaSource: "Sursa antenei" antennaKeywords: "Cuvinte cheie ascultate" antennaExcludeKeywords: "Cuvinte cheie excluse" -antennaKeywordsDescription: "Separă cu spații pentru o condiție ȘI sau cu o întrerupere de rând pentru o condiție SAU." +antennaExcludeBots: "Exclude conturi tip bot" +antennaKeywordsDescription: "Separă cu spații pentru o condiție ''AND'' sau cu o linie de spațiere nouă pentru o condiție ''OR''." notifyAntenna: "Notifică-mă pentru note noi" withFileAntenna: "Doar note cu fișiere" +excludeNotesInSensitiveChannel: "Exclude note din canale sensibile" enableServiceworker: "Activează ServiceWorker" antennaUsersDescription: "Scrie un nume de utilizator per linie" caseSensitive: "Sensibil la majuscule și minuscule" @@ -384,13 +433,13 @@ connectedTo: "Următoarele conturi sunt conectate" notesAndReplies: "Note și răspunsuri" withFiles: "Incluzând fișiere" silence: "Amuțește" -silenceConfirm: "Ești sigur că vrei să amuțești acest utilizator?" +silenceConfirm: "Ești sigur(ă) că vrei să amuțești acest utilizator?" unsilence: "Anulează amuțirea" -unsilenceConfirm: "Ești sigur că vrei să anulezi amuțirea acestui utilizator?" +unsilenceConfirm: "Ești sigur(ă) că vrei să anulezi amuțirea acestui utilizator?" popularUsers: "Utilizatori populari" recentlyUpdatedUsers: "Utilizatori activi recent" recentlyRegisteredUsers: "Utilizatori ce s-au alăturat recent" -recentlyDiscoveredUsers: "Utilizatori descoperiți recent" +recentlyDiscoveredUsers: "Utilizatori recent descoperiți" exploreUsersCount: "Aici sunt {count} utilizatori" exploreFediverse: "Explorează Fediverse-ul" popularTags: "Taguri populare" @@ -399,12 +448,24 @@ about: "Despre" aboutMisskey: "Despre Misskey" administrator: "Administrator" token: "Token" +2fa: "Autentificare cu doi factori" +setupOf2fa: "Configurează autentificarea cu doi factori" +totp: "Aplicația de autentificare" +totpDescription: "Folosește o aplicație de autentificare pentru a putea utiliza parole de unica folosință" moderator: "Moderator" +moderation: "Moderare" +moderationNote: "Note de moderare" +moderationNoteDescription: "Poți completa note care vor fi partajate doar între moderatori." +addModerationNote: "Adaugă o notă de moderare" +moderationLogs: "Jurnal de moderare" nUsersMentioned: "Menționat de {n} utilizatori" +securityKeyAndPasskey: "Cheie de securitate - cheie de acces " securityKey: "Cheie de securitate" lastUsed: "Ultima utilizată" +lastUsedAt: "Ultima utilizare: {t}" unregister: "Dezînregistrează" passwordLessLogin: "Autentificare fără parolă" +passwordLessLoginDescription: "Permite autentificare fără parolă folosind doar o cheie de securitate sau o cheie de acces" resetPassword: "Resetează parola" newPasswordIs: "Noua parolă este \"{password}\"" reduceUiAnimation: "Redu animațiile interfeței" @@ -429,10 +490,10 @@ retype: "Introdu din nou" noteOf: "Notă de {user}" quoteAttached: "Citat" quoteQuestion: "Vrei să adaugi ca citat?" -noMessagesYet: "Niciun mesaj încă" -newMessageExists: "Ai mesaje noi" +attachAsFileQuestion: "Textul clipboard-ului este lung. Dorești să-l atașezi ca fișier text?" onlyOneFileCanBeAttached: "Poți atașa un singur fișier la un mesaj" signinRequired: "Te rog autentifică-te" +signinOrContinueOnRemote: "Pentru a continua, trebuie să mergi la serverul dvs. sau să te înregistrezi și să te conectezi la acest server." invitations: "Invită" invitationCode: "Cod de invitație" checking: "Se verifică..." @@ -447,13 +508,23 @@ strongPassword: "Parolă puternică" passwordMatched: "Se potrivește!" passwordNotMatched: "Nu se potrivește" signinWith: "Autentifică-te cu {x}" -signinFailed: "Nu se poate autentifica. Numele de utilizator sau parola introduse sunt incorecte." +signinFailed: "Nu se poate autentifica. Numele de utilizator sau parola introdusă e incorectă." or: "Sau" language: "Limbă" uiLanguage: "Limba interfeței" aboutX: "Despre {x}" +emojiStyle: "Stil emoji" +native: "Nativ" +menuStyle: "Stilul meniului" +style: "Stil" +drawer: "Sertar" +popup: "Pop up" +showNoteActionsOnlyHover: "Afișează acțiunile de notare numai la trecerea cursorului" +showReactionsCount: "Afișează numărul de reacții la note" noHistory: "Nu există istoric" signinHistory: "Istoric autentificări" +enableAdvancedMfm: "Permite autentificarea multiplă(MFM) avansată" +enableAnimatedMfm: "Permite autentificarea multiplă(MFM) animată" doing: "Se procesează..." category: "Categorie" tags: "Etichete" @@ -462,6 +533,8 @@ createAccount: "Creează un cont" existingAccount: "Cont existent" regenerate: "Regenerează" fontSize: "Mărimea fontului" +mediaListWithOneImageAppearance: "Înălțimea listelor media cu o singură imagine" +limitTo: "Limitează până la {x}" noFollowRequests: "Nu ai nicio cerere de urmărire în așteptare" openImageInNewTab: "Deschide imaginile în taburi noi" dashboard: "Panou de control" @@ -495,9 +568,12 @@ objectStorageUseSSLDesc: "Oprește această opțiune dacă nu vei folosi HTTPS p objectStorageUseProxy: "Conectează-te prin Proxy" objectStorageUseProxyDesc: "Oprește această opțiune dacă vei nu folosi un Proxy pentru conexiunile API-ului" objectStorageSetPublicRead: "Setează \"public-read\" pentru încărcare" +s3ForcePathStyleDesc: "Dacă s3ForcePathStyle este activat, numele compartimentului trebuie inclus în calea adresei URL, spre deosebire de numele de gazdă(hostname) al adresei URL. Poate fi necesar să activezi această setare atunci când utilizezi servicii precum o instanță Minio găzduită de sine(self-hosted)." serverLogs: "Loguri server" deleteAll: "Șterge tot" showFixedPostForm: "Arată caseta de postare în vârful cronologie" +showFixedPostFormInChannel: "Afișează formularul de postare în partea de sus a cronologiei (Canale)" +withRepliesByDefaultForNewlyFollowed: "Include în mod prestabilit răspunsurile utilizatorilor nou urmăriți în cronologie" newNoteRecived: "Sunt note noi" sounds: "Sunete" sound: "Sunete" @@ -507,37 +583,51 @@ showInPage: "Arată în pagină" popout: "Scoate în afară" volume: "Volum" masterVolume: "Volumul principal" +notUseSound: "Oprește sunetul" +useSoundOnlyWhenActive: "Sunetele se aud numai dacă fereastra de Misskey este activă" details: "Detalii" +renoteDetails: "Detalii de re-notare" chooseEmoji: "Alege un emoji" unableToProcess: "Această operație nu poate fi completată" -recentUsed: "Folosit recent" +recentUsed: "Folosit(e) recent" install: "Instalează" uninstall: "Dezinstalează" installedApps: "Aplicații autorizate" nothing: "Nu e nimic de văzut aici" installedDate: "Autorizat la data de" -lastUsedDate: "Folosit ultima oara la" +lastUsedDate: "Folosit(e) ultima oara la" state: "Stare" sort: "Sortează" ascendingOrder: "Crescător" descendingOrder: "Descrescător" scratchpad: "Scratchpad" scratchpadDescription: "Scratchpad-ul oferă un mediu de experimentare în AiScript. Poți scrie, executa și verifica rezultatele acestuia interacționând cu Misskey în el." +uiInspector: "Inspector UI" +uiInspectorDescription: "Poți vedea lista de servere de componente UI în memorie. Componenta UI va fi generată de funcția Ui:C:." output: "Ieșire" script: "Script" disablePagesScript: "Dezactivează AiScript în Pagini" updateRemoteUser: "Actualizează informațiile utilizatorului extern" +unsetUserAvatar: "Anulează avatarul" +unsetUserAvatarConfirm: "Ești sigur(ă) că vrei sa anulezi avatarul?" +unsetUserBanner: "Avatarul utilizatorului a fost anulat" +unsetUserBannerConfirm: "Ești sigur(ă) că vrei sa anulezi bannerul?" deleteAllFiles: "Șterge toate fișierele" deleteAllFilesConfirm: "Ești sigur că vrei să ștergi toate fișierele?" -removeAllFollowing: "Dezurmărește toți utilizatorii urmăriți" -removeAllFollowingDescription: "Asta va dez-urmări toate conturile din {host}. Te rog execută asta numai dacă instanța, de ex., nu mai există." +removeAllFollowing: "Elimină toți utilizatorii urmăriți" +removeAllFollowingDescription: "Asta va elimina urmărirea tuturor conturilor din {host}. Te rog execută asta numai dacă instanța, de ex., nu mai există." userSuspended: "Acest utilizator a fost suspendat." userSilenced: "Acest utilizator a fost setat silențios." yourAccountSuspendedTitle: "Acest cont a fost suspendat" yourAccountSuspendedDescription: "Acest cont a fost suspendat din cauza încălcării termenilor de serviciu al serverului sau ceva similar. Contactează administratorul dacă ai dori să afli un motiv mai detaliat. Te rog nu crea un cont nou." +tokenRevoked: "Token invalid" +tokenRevokedDescription: "Token-ul a expirat.\nTe rugăm sa te reloghezi." +accountDeleted: "Cont șters." +accountDeletedDescription: "Acest cont a fost eliminat." menu: "Meniu" divider: "Separator" addItem: "Adaugă element" +rearrange: "Rearanjează" relays: "Relee" addRelay: "Adaugă Releu" inboxUrl: "URL-ul inbox-ului" @@ -560,9 +650,11 @@ author: "Autor" leaveConfirm: "Ai schimbări nesalvate. Vrei să renunți la ele?" manage: "Gestionare" plugins: "Pluginuri" +preferencesBackups: "Copii de rezervă ale preferințelor" deck: "Deck" undeck: "Părăsește Deck" useBlurEffectForModal: "Folosește efect de blur pentru modale" +useFullReactionPicker: "Utilizează selectorul de reacții de dimensiune completă" width: "Lăţime" height: "Înălţime" large: "Mare" @@ -570,6 +662,7 @@ medium: "Mediu" small: "Mic" generateAccessToken: "Generează token de acces" permission: "Permisiuni" +adminPermission: "Permisiuni administrator" enableAll: "Actevează tot" disableAll: "Dezactivează tot" tokenRequested: "Acordă acces la cont" @@ -591,20 +684,26 @@ smtpSecure: "Folosește SSL/TLS implicit pentru conecțiunile SMTP" smtpSecureInfo: "Oprește opțiunea asta dacă STARTTLS este folosit" testEmail: "Testează livrarea emailurilor" wordMute: "Cuvinte pe mut" +wordMuteDescription: "Minimizează notele care conțin cuvântul sau expresia specificată. Notele minimizate pot fi afișate făcând clic pe ele." +hardWordMute: "Amuțire pe cuvinte grele" +showMutedWord: "Arata cuvintele amuțite" +hardWordMuteDescription: "Ascunde notele care conțin fraza specificată. Spre deosebire de cuvintele amuțite, notele vor fi complet ascunse." regexpError: "Eroare de Expresie Regulată" regexpErrorDescription: "A apărut o eroare în expresia regulată pe linia {line} al cuvintelor {tab} setate pe mut:" instanceMute: "Instanțe pe mut" userSaysSomething: "{name} a spus ceva" +userSaysSomethingAbout: "{name} a scris ceva despre {name}" makeActive: "Activează" display: "Arată" copy: "Copiază" +copiedToClipboard: "Copiat în clipboard." metrics: "Metrici" overview: "Privire de ansamblu" logs: "Log-uri" delayed: "Întârziate" database: "Baza de date" channel: "Canale" -create: "Crează" +create: "Creează" notificationSetting: "Setări notificări" notificationSettingDesc: "Selectează tipurile de notificări care să fie arătate" useGlobalSetting: "Folosește setările globale" @@ -612,12 +711,14 @@ useGlobalSettingDesc: "Dacă opțiunea e pornită, notificările contului tău v other: "Altele" regenerateLoginToken: "Regenerează token de login" regenerateLoginTokenDescription: "Regenerează token-ul folosit intern în timpul logări. În mod normal asta nu este necesar. Odată regenerat, toate dispozitivele vor fi delogate." +theKeywordWhenSearchingForCustomEmoji: "Acesta este cuvântul cheie atunci când cauți emoji-uri personalizate." setMultipleBySeparatingWithSpace: "Separă mai multe intrări cu spații." fileIdOrUrl: "Introdu ID sau URL" behavior: "Comportament" sample: "exemplu" abuseReports: "Rapoarte" reportAbuse: "Raportează" +reportAbuseRenote: "Raportați Re-nota" reportAbuseOf: "Raportează {name}" fillAbuseReportDescription: "Te rog scrie detaliile legate de acest raport. Dacă este despre o notă specifică, te rog introdu URL-ul ei." abuseReported: "Raportul tău a fost trimis. Mulțumim." @@ -629,39 +730,553 @@ openInNewTab: "Deschide în tab nou" openInSideView: "Deschide în vedere laterală" defaultNavigationBehaviour: "Comportament de navigare implicit" editTheseSettingsMayBreakAccount: "Editarea acestor setări îți pot defecta contul." +instanceTicker: "Informații de instanță ale notelor" waitingFor: "Așteptând pentru {x}" -random: "Aleator" +random: "Aleatoriu" system: "Sistem" switchUi: "Schimbă UI" desktop: "Desktop" +clip: "Clip" +createNew: "Creează ceva nou" +optional: "Opțional" +createNewClip: "Creează un clip nou" +unclip: "Anulează clipul" +confirmToUnclipAlreadyClippedNote: "Această notă face deja parte din clipul „{name}”. Dorești, în schimb, să îl elimini din acest clip?" +public: "Public" +private: "Privat" +i18nInfo: "Misskey este tradusă în diferite limbi de către voluntari. Puteți ajuta accesând {link}." +manageAccessTokens: "Gestionați token-urile de acces" +accountInfo: "Informațiile contului" +notesCount: "Numărul de note" +repliesCount: "Numărul de răspunsuri trimise" +renotesCount: "Numărul de Re-Note trimise" +repliedCount: "Numărul de răspunsuri primite" +renotedCount: "Numărul de Re-Note primite" +followingCount: "Numărul de conturi urmărite" +followersCount: "Numărul de urmăritori" +sentReactionsCount: "Numărul de reacții trimise" +receivedReactionsCount: "Numărul de reacții primite" +pollVotesCount: "Numărul de voturi trimise la sondaj" +pollVotedCount: "Numărul de voturi în sondaj" +yes: "Da" +no: "Nu" +driveFilesCount: "Numărul de fișiere din drive" +driveUsage: "Gestionati spatiul de utilizare a drive-ului" +noCrawle: "Respingeți indexarea prin crawler" +noCrawleDescription: "Cere motoarelor de căutare să nu indexeze pagina de profil, noteele, paginile etc." +lockedAccountInfo: "Dacă nu setați vizibilitatea notei la „Numai persoane interesate”, notele vor fi vizibile pentru oricine, chiar dacă aveți nevoie de aprobarea manuală a persoanelor interesate." +alwaysMarkSensitive: "Marcați ca sensibil în mod prestabilit" +loadRawImages: "Încărcați imagini originale în loc să afișați miniaturile" +disableShowingAnimatedImages: "Nu reda imaginile animate" +highlightSensitiveMedia: "Evidențiază conținutul media sensibil" +verificationEmailSent: "A fost trimis un e-mail de confirmare. Urmează linkul din e-mail pentru a finaliza configurarea." +notSet: "Nesetat" +emailVerified: "E-mailul a fost verificat" +noteFavoritesCount: "Numărul de note preferate" +pageLikesCount: "Numărul de pagini apreciate" +pageLikedCount: "Numărul de aprecieri primite pe pagină" +contact: "Contact" +useSystemFont: "Utilizați fontul implicit al sistemului" +clips: "Clip" +experimentalFeatures: "Funcții experimentale" +experimental: "Experimental" +thisIsExperimentalFeature: "Aceasta este o funcție experimentală. Funcționalitatea sa este supusă modificării și este posibil să nu funcționeze conform intenției." +developer: "Dezvoltator" +makeExplorable: "Fă-ți contul vizibil în secțiunea„Explorați”" +makeExplorableDescription: "Dacă dezactivezi această opțiune, contul dvs. nu va fi vizibil în secțiunea\"Explorați\"." +showGapBetweenNotesInTimeline: "Afișați un decalaj între postările de pe cronologie" +duplicate: "Duplicat" +left: "Stânga" +center: "Centru" +wide: "Lat" +narrow: "Îngust" +reloadToApplySetting: "Setările vor fi replicate după reîncărcarea paginii." +needReloadToApply: "Este necesară o reîncărcare pentru ca acest lucru să se replice." +showTitlebar: "Afișează bara de titlu" clearCache: "Golește cache-ul" +onlineUsersCount: "{n} de utilizatori online" +nUsers: "{n} Utilizatori" +nNotes: "{n} de note" +sendErrorReports: "Trimite rapoartele de eroare" +sendErrorReportsDescription: "Când este pornit, informațiile detaliate despre erori vor fi partajate cu Misskey atunci când apare o problemă, ajutând la îmbunătățirea calității Misskey.\nAceasta va include informații precum versiunea sistemului de operare, ce browser utilizați, activitatea dvs. în Misskey etc." +myTheme: "Tema mea" +backgroundColor: "Culoare de fundal" +accentColor: "Culoare de accent" +textColor: "Culoarea textului" +saveAs: "Salvează ca..." +advanced: "Avansat" +advancedSettings: "Setări Avansate" +value: "Valoare" +createdAt: "Creat în" +updatedAt: "Actualizat la" +saveConfirm: "Salvezi modificările?" +deleteConfirm: "Sigur vrei să ștergi?" +invalidValue: "Valoare invalidă." +registry: "Registru" +closeAccount: "Șterge contul" +currentVersion: "Versiunea curentă" +latestVersion: "Versiunea cea mai nouă" +youAreRunningUpToDateClient: "Utilizezi cea mai nouă versiune a clientului" +newVersionOfClientAvailable: "Este disponibilă o nouă versiune a clientului." +usageAmount: "Utilizare" +capacity: "Capacitate" +inUse: "Folosit" +editCode: "Editează codul" +apply: "Aplică" +receiveAnnouncementFromInstance: "Primește notificări de la această instanță" +emailNotification: "Notificări prin e-mail" +publish: "Publică" +inChannelSearch: "Caută pe canal" +useReactionPickerForContextMenu: "Deschide selectorul de reacții făcând clic dreapta" +typingUsers: "{users} scriu/e chiar acum..." +jumpToSpecifiedDate: "Sari la o anumită dată" +showingPastTimeline: "În prezent, se afișează o cronologie veche" +clear: "Întoarce-te" +markAllAsRead: "Marchează ca ,,citit”" +goBack: "Înapoi" +unlikeConfirm: "Chiar îți elimini like-ul?" +fullView: "Ecran complet" +quitFullView: "Ieși din ecranul complet" +addDescription: "Adaugă o descriere" +userPagePinTip: "Poți afișa notele aici selectând „fixează pe profil” din meniul individual al fiecărei note " +notSpecifiedMentionWarning: "Există mențiuni ce nu sunt incluse în lista de destinatari" info: "Despre" +userInfo: "Informații despre utilizator" +unknown: "Necunoscut" +onlineStatus: "Stare online" +hideOnlineStatus: "Ascunde starea online" +hideOnlineStatusDescription: "Ascunderea stării dvs. online reduce confortul unor funcții, cum ar fi căutarea." +online: "Online" +active: "Disponibil" +offline: "Offline" +notRecommended: "Nerecomandat" +botProtection: "Protecție boți" +instanceBlocking: "Instanțe blocate/ascunse" +selectAccount: "Selectează un cont" +switchAccount: "Schimbă contul" +enabled: "Activat" +disabled: "Dezactivat" +quickAction: "Acțiuni rapide" user: "Utilizatori" administration: "Gestionare" +accounts: "Conturi" +switch: "Schimbă" +noMaintainerInformationWarning: "Informațiile întreținătorului nu sunt configurate." +noInquiryUrlWarning: "Adresa URL de cereri de informații nu este setata" +noBotProtectionWarning: "Protecția împotriva boților nu este configurată." +configure: "Configurează" +postToGallery: "Creează o postare nouă în galerie" +postToHashtag: "Postează pe acest hashtag" +gallery: "Galerie" +recentPosts: "Postări recente" +popularPosts: "Postări populare" +shareWithNote: "Distribuie cu notă" +ads: "Reclame" +expiration: "Termen limită" +startingperiod: "Start" +memo: "Memo" +priority: "Prioritate" +high: "Ridicată" middle: "Mediu" +low: "Scăzuta" +emailNotConfiguredWarning: "Adresa de e-mail nu este setată." +ratio: "Rație" +previewNoteText: "Afișează previzualizarea" +customCss: "CSS personalizat" +customCssWarn: "Această setare ar trebui folosită numai dacă știi ce face. Introducerea unor valori necorespunzătoare poate determina clientul să nu mai funcționeze normal." +global: "Global" +squareAvatars: "Afișează avatarele pătrate" sent: "Trimite" +received: "Primite" +searchResult: "Rezultate căutare" +hashtags: "Hashtag-uri" +troubleshooting: "Diagnosticare" +useBlurEffect: "Utilizează efecte de estompare în interfața de utilizare" +learnMore: "Află mai multe" +misskeyUpdated: "Misskey a fost actualizat!" +whatIsNew: "Vezi noile modificări" +translate: "Tradu" +translatedFrom: "Tradus din {x}" +accountDeletionInProgress: "Ștergerea contului este în curs de desfășurare" +usernameInfo: "Un nume care vă identifică contul de alții de pe acest server. Poți folosi alfabetul (a~z, A~Z), cifrele (0~9) sau litere de subliniere (_). Numele de utilizator nu pot fi schimbate ulterior." +aiChanMode: "Modul Ai" +devMode: "Modul Dezvoltator" +keepCw: "Păstrează avertismentele de conținut" +pubSub: "Conturi de Pub/Sub" +lastCommunication: "Ultima comunicare" +resolved: "Rezolvat" +unresolved: "Nerezolvat" +breakFollow: "Elimină urmăritorul" +breakFollowConfirm: "Chiar eliminați această urmărire?" +itsOn: "Activat" +itsOff: "Dezactivat" +on: "Pornit" +off: "Oprit" +emailRequiredForSignup: "E nevoie de o adresă de e-mail pentru înregistrare" +unread: "Necitit/e" +filter: "Filtru" +controlPanel: "Panou de Control" +manageAccounts: "Gestionează Conturile" +makeReactionsPublic: "Setați istoricul reacțiilor să fie public" +makeReactionsPublicDescription: "Faceți-vă reacțiile vizibile pentru toată lumea" +classic: "Clasic" +muteThread: "Amuțește thread-ul" +unmuteThread: "Dezmuțește thread-ul" +followingVisibility: "Vizibilitatea celor pe care ii urmărești" +followersVisibility: "Vizibilitatea celor care te urmărește" +continueThread: "Continuă thread-ul" +deleteAccountConfirm: "Acest lucru vă va șterge ireversibil contul. Continui?" +incorrectPassword: "Parolă incorectă." +incorrectTotp: "Parola unică este incorectă sau a expirat." +voteConfirm: "Confirmi votul pentru „{choice}”?" +hide: "Ascunde" +useDrawerReactionPickerForMobile: "Afișează selectorul de reacții ca sertar pe mobil" +welcomeBackWithName: "Bine ai revenit, {name}" +clickToFinishEmailVerification: "Dați clic pe [{ok}] pentru a finaliza verificarea e-mailului." +overridedDeviceKind: "Tipul de dispozitiv" +smartphone: "Smartphone" +tablet: "Tableta" +auto: "Auto" +themeColor: "Culoarea temei" +size: "Dimensiune" +numberOfColumn: "Numărul de coloane" searchByGoogle: "Caută" +instanceDefaultLightTheme: "Tema luminoasă implicită la nivelul întregii instanțe" +instanceDefaultDarkTheme: "Tema întunecată implicită la nivelul întregii instanțe" +instanceDefaultThemeDescription: "Introduceți codul temei în format obiect." +mutePeriod: "Durata amuțire" +period: "Timp limită" +indefinitely: "Permanent" +tenMinutes: "10 minute" +oneHour: "O oră" +oneDay: "O zi" +oneWeek: "O săptămâna" +oneMonth: "O lună" +threeMonths: "Trei luni" +oneYear: "Un an" +threeDays: "Trei zile" +reflectMayTakeTime: "Poate dura ceva timp pentru ca acest lucru să se replice." +failedToFetchAccountInformation: "Nu s-a putut prelua informațiile despre cont" +rateLimitExceeded: "Limita ratei a fost depășită" +cropImage: "Trunchiază imaginea" +cropImageAsk: "Dorești să trunchiezi această imagine?" +cropYes: "Trunchiază" +cropNo: "Utilizează-o așa cum e" file: "Fișiere" +recentNHours: "Ultimele {n} ore" +recentNDays: "Ultimele {n} zile" +noEmailServerWarning: "Serverul de e-mail nu este configurat." +thereIsUnresolvedAbuseReportWarning: "Sunt rapoarte nerezolvate." +recommended: "Recomandat" +check: "Verifică" +driveCapOverrideLabel: "Schimbă capacitatea de stocare a drive-ului pentru acest utilizator" +driveCapOverrideCaption: "Resetează capacitatea la valoarea implicită introducând o valoare de 0 sau mai mică." +requireAdminForView: "Trebuie să te conectezi cu un cont de administrator pentru a vedea această resursă." +isSystemAccount: "Un cont creat și operat automat de sistem." +typeToConfirm: "Introdu {x} pentru a confirma" +deleteAccount: "Șterge contul" +document: "Documentație" +numberOfPageCache: "Număr de pagini stocate cache" +numberOfPageCacheDescription: "Mărirea acestui număr va îmbunătăți conveniența, dar va cauza mai multă sarcină pe măsură ce se utilizează mai multă memorie pe dispozitivul utilizatorului.\n" +logoutConfirm: "Ești sigur(ă) că vrei să te deloghezi?" +logoutWillClearClientData: "Deconectarea va șterge setările clientului din browser. Pentru a putea restabili setările la autentificare, trebuie să activezi copia de rezervă automată a setărilor." +lastActiveDate: "Ultima dată de utilizare" +statusbar: "Bară de stare" +pleaseSelect: "Alege o opțiune" +reverse: "Invers" +colored: "Colorat" +refreshInterval: "Interval de actualizare" +label: "Etichetă" +type: "Tip" +speed: "Viteză" +slow: "Lent" +fast: "Rapid" +sensitiveMediaDetection: "Detectarea conținutului media sensibil" +localOnly: "Beta" +remoteOnly: "Doar externe" +failedToUpload: "Încărcare eșuată" +cannotUploadBecauseInappropriate: "Acest fișier nu a putut fi încărcat deoarece părți din acesta au fost detectate ca potențial neadecvate." +cannotUploadBecauseNoFreeSpace: "Încărcarea a eșuat datorită lipsei spațiului din drive." +cannotUploadBecauseExceedsFileSizeLimit: "Acest fișier nu poate fi încărcat deoarece depășește limita de dimensiune a fișierelor." +beta: "Beta" +enableAutoSensitive: "Marcare automată ca fiind conținut sensibil" +enableAutoSensitiveDescription: "Permite detectarea și marcarea automată a mediilor sensibile prin Machine Learning acolo unde este posibil. Chiar dacă această opțiune este dezactivată ea poate fi, în schimb, activă la nivelul întregii instanțe." +activeEmailValidationDescription: "Permite validarea mai strictă a adreselor de e-mail, care includ verificarea adreselor de unică folosință și dacă pot fi comunicate cu acestea. Când este debifat, este validat doar formatul e-mailului." +navbar: "Bara de navigare" +shuffle: "Amestecă" +account: "Conturi" +move: "Mută" +pushNotification: "Notificări tip „push”" +subscribePushNotification: "Permite notificările tip „push”" +unsubscribePushNotification: "Oprește notificările tip „push”" +pushNotificationAlreadySubscribed: "Notificările tip „push” sunt deja activate" +pushNotificationNotSupported: "Browserul sau instanța dvs. nu acceptă notificările tip „push”" +sendPushNotificationReadMessage: "Șterge notificările tip „push” după ce au fost citite" +sendPushNotificationReadMessageCaption: "Acest lucru poate crește consumul de energie al dispozitivului" +windowMaximize: "Maximizează" +windowMinimize: "Minimizează" +windowRestore: "Restabilește" +caption: "Titrare" +loggedInAsBot: "Conectat în prezent ca bot" +tools: "Unelte" +cannotLoad: "Nu se poate încărca" +numberOfProfileView: "Numărul de vizualizări ale profilului" +like: "Îmi place!" +unlike: "Îmi displace" +numberOfLikes: "Numărul de aprecieri" show: "Arată" +neverShow: "Nu mai afișa" +remindMeLater: "Poate mai târziu" +didYouLikeMisskey: "A început sa îți placa Misskey?" +pleaseDonate: "{host} folosește software-ul gratuit, Misskey. Am aprecia foarte mult donațiile dumneavoastră, astfel încât dezvoltarea Misskey să poată continua!" +correspondingSourceIsAvailable: "Codul sursă corespunzător este disponibil la {anchor}" +roles: "Roluri" +role: "Roluri" +noRole: "Rolul nu a fost găsit" +normalUser: "Utilizator obișnuit" +undefined: "Nedefinit" +assign: "Asignează" +unassign: "Dezasignează" +color: "Culoare" +manageCustomEmojis: "Gestionează emoji-uri personalizate" +manageAvatarDecorations: "Gestionați decorațiunile avatarului" +youCannotCreateAnymore: "Ai atins limita de creație." +cannotPerformTemporary: "Temporar indisponibil" +cannotPerformTemporaryDescription: "Această acțiune nu poate fi efectuată temporar din cauza depășirii limitei de execuție. Te rugăm să aștepți puțin și apoi să încerci din nou." +invalidParamError: "Parametri invalizi" +invalidParamErrorDescription: "Parametrii cererii sunt invalizi. Acest lucru este cauzat în mod normal de o eroare, dar se poate datora și intrărilor care depășesc limitele de dimensiune sau altceva similar." +permissionDeniedError: "Operațiune refuzată" +permissionDeniedErrorDescription: "Acest cont nu are permisiunea de a efectua această acțiune." +preset: "Presetate" +selectFromPresets: "Alege din presetate" +achievements: "Realizări" +gotInvalidResponseError: "Răspunsul serverului este invalid" +gotInvalidResponseErrorDescription: "Serverul poate fi oprit sau e în curs de întreținere. Te rugăm să încerci din nou după un timp." +thisPostMayBeAnnoying: "Această notă îi poate deranja pe alții." +thisPostMayBeAnnoyingHome: "Postează în cronologia de acasă" +thisPostMayBeAnnoyingCancel: "Anulează" +thisPostMayBeAnnoyingIgnore: "Postează oricum" +collapseRenotes: "Restrânge Re-Notările pe care le-ați văzut deja" +collapseRenotesDescription: "Restrânge notările pe care le-ați văzut deja" +internalServerError: "Eroare interna a serverului" +internalServerErrorDescription: "Serverul a întâmpinat o eroare neașteptată." +copyErrorInfo: "Copiază detaliile erorii" +joinThisServer: "Înregistrează-te în această instanță" +exploreOtherServers: "Caută o altă instanță" +letsLookAtTimeline: "Aruncă o privire la cronologie" +disableFederationConfirm: "Sigur vrei sa oprești federarea" +disableFederationConfirmWarn: "Chiar dacă sunt defederate, postările vor continua să fie publice, dacă nu sunt stabilite altfel. De obicei, nu trebuie să faceți acest lucru." +disableFederationOk: "Dezactivează" +invitationRequiredToRegister: "Acest server este în prezent accesibil numai pe bază de invitație. Se pot înregistra doar cei care au cod de invitație." +emailNotSupported: "Această instanță nu acceptă trimiterea de e-mailuri" +postToTheChannel: "Postează pe canal" +cannotBeChangedLater: "Nu poate fi schimbat ulterior" +reactionAcceptance: "Acceptarea reacțiilor" +likeOnly: "Doar aprecieri" +likeOnlyForRemote: "Toate (aplicabil numai pentru instanțe externe)" +nonSensitiveOnly: "Numai conținut non-sensibil" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "Numai non-sensibile (aplicabil numai pentru aprecieri de la surse externe)" +rolesAssignedToMe: "Roluri asignate mie" +resetPasswordConfirm: "Sigur vrei sa îți resetezi parola" +sensitiveWords: "Cuvinte sensibile" +sensitiveWordsDescription: "Vizibilitatea tuturor notelor care conțin oricare dintre cuvintele configurate va fi setate automat la „Acasă”. Puteți enumera mai multe, separându-le prin o linie de spațiere nouă." +sensitiveWordsDescription2: "Folosirea spațiilor va crea expresii \"AND\" și înconjurând cuvintele cheie cu bare oblice le vor transforma într-o expresie obișnuită." +prohibitedWords: "Cuvinte interzise" +prohibitedWordsDescription: "Activează o eroare la încercarea de a posta o notă care conține cuvintele setate. Pot fi setate mai multe cuvinte, separate printr-o linie de spațiere nouă." +prohibitedWordsDescription2: "Folosirea spațiilor va crea expresii \"AND\" și înconjurând cuvintele cheie cu bare oblice le vor transforma într-o expresie obișnuită." +hiddenTags: "Hashtag-uri ascunse" +hiddenTagsDescription: "Selectați hashtag-uri care nu vor fi afișate în lista de tendințe.\nMai multe hashtag-uri pot fi înregistrate pe o linie de spațiere noua." +notesSearchNotAvailable: "Căutarea notelor este indisponibilă." +license: "Licență" +unfavoriteConfirm: "Sigur vrei să elimini din favorite?" +myClips: "Clipurile mele" +drivecleaner: "Curățitorul de drive" +retryAllQueuesNow: "Reîncearcă să rulezi toate cozile" +retryAllQueuesConfirmTitle: "Sigur vrei să le reîncerci din nou?" +retryAllQueuesConfirmText: "Acest lucru va crește temporar încărcarea rulării serverului." +enableChartsForRemoteUser: "Generează diagrame cu datele utilizatorilor externi" +enableChartsForFederatedInstances: "Generează diagrame de date ale instanțelor externe" +enableStatsForFederatedInstances: "Primește statistici ale serverelor externe" +showClipButtonInNoteFooter: "Adaugă „Clip” la meniul de acțiuni pentru note" +reactionsDisplaySize: "Dimensiunea afișajului de reacție" +limitWidthOfReaction: "Limitează lățimea maximă a reacțiilor și afișează-le în dimensiuni reduse." +noteIdOrUrl: "ID sau URL-ul notei" +video: "Video" +videos: "Video-uri" +audio: "Audio" +audioFiles: "Audio" +dataSaver: "Economizor de date" +accountMigration: "Migrarea contului" +accountMoved: "Acest utilizator a fost mutat într-un alt cont:" +accountMovedShort: "Acest cont a fost migrat." +operationForbidden: "Operațiune interzisă" +forceShowAds: "Afișează întotdeauna reclame" +addMemo: "Adaugă un memo" +editMemo: "Editează memo-ul" +reactionsList: "Reacții" +renotesList: "Re-Notări" +notificationDisplay: "Notificări" +leftTop: "Stânga-sus" +rightTop: "Dreapta-sus" +leftBottom: "Stânga-jos" +rightBottom: "Dreapta-jos" +stackAxis: "Direcția de stack-are" +vertical: "Vertical" +horizontal: "Orizontal" +position: "Poziție" +serverRules: "Regulamentul serverului" +pleaseConfirmBelowBeforeSignup: "Pentru a te înregistra pe acest server, trebuie să examinezi și să fii de acord cu următoarele:" +pleaseAgreeAllToContinue: "Trebuie să fii de acord cu toate câmpurile de mai sus pentru a continua." +continue: "Continuă" +preservedUsernames: "Nume rezervate de utilizator" +preservedUsernamesDescription: "Listeaza numele de utilizatori pentru a le rezerva, separate prin întreruperi de linie. Acestea vor deveni inutilizabile în timpul creării normale a contului, dar pot fi folosite de administratori pentru a crea conturi manual. Conturile deja existente care folosesc aceste nume de utilizator nu vor fi afectate." +createNoteFromTheFile: "Compuneți o notă din acest fișier" +archive: "Arhivă" +archived: "Arhivat" +unarchive: "Nearhivabil" +channelArchiveConfirmTitle: "Sigur vrei să arhivezi {name}?" +channelArchiveConfirmDescription: "Un canal arhivat nu va mai apărea în lista de canale sau în rezultatele căutării. De asemenea, postările noi nu mai pot fi adăugate la acesta." +thisChannelArchived: "Acest canal a fost arhivat." +displayOfNote: "Afișajul notelor" +initialAccountSetting: "Configurarea Profilului" +youFollowing: "Îl urmărești" +preventAiLearning: "Respinge utilizarea în Machine Learning (IA generativă)" +preventAiLearningDescription: "Solicită crawlerilor să nu folosească textul sau materialul de imagine postat etc. în seturile de date de învățare automată (AI predictivă/generativă). Acest lucru se realizează prin adăugarea unui flag „noai” HTML-Response la conținutul respectiv. Cu toate acestea, o prevenire completă nu poate fi realizată prin acest flag, deoarece poate fi pur și simplu ignorat." +options: "Opțiuni" +specifyUser: "Utilizator specific" +lookupConfirm: "Vrei să cauți?" +openTagPageConfirm: "Vrei să deschizi o pagină cu hashtag?" +specifyHost: "O gazdă(host) specifică" +failedToPreviewUrl: "Nu se poate previzualiza" +update: "Actualizare" +rolesThatCanBeUsedThisEmojiAsReaction: "Roluri care pot folosi acest emoji ca reacție" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Dacă nu sunt specificate rolurile, cineva poate folosi acest emoji ca reacție." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Aceste roluri trebuie să fie publice." +cancelReactionConfirm: "Ești sigur(ă) că vrei să ștergi reacția ta?" +changeReactionConfirm: "Sigur vrei sa îți ștergi reacția?" +later: "Mai târziu" +goToMisskey: "Spre Misskey" +additionalEmojiDictionary: "Dicționare emoji suplimentare" +installed: "Instalat" +branding: "Branding" +enableServerMachineStats: "Publicați statistici hardware ale serverului" +enableIdenticonGeneration: "Activați generarea identicon a utilizatorului" +turnOffToImprovePerformance: "Oprirea acestei opțiuni poate crește performanța." +createInviteCode: "Generează invitația" +createWithOptions: "Generează cu opțiuni" +createCount: "Numărul de invitații" +inviteCodeCreated: "Invitație generată" +inviteLimitExceeded: "Ați depășit limita invitațiilor pe care le puteți genera." +createLimitRemaining: "Limită invitații : {limit} rămase" +inviteLimitResetCycle: "Această limită se va reseta la {limit} la {time}." +expirationDate: "Data de expirare" +noExpirationDate: "Fără expirare" +inviteCodeUsedAt: "Codul de invitație în" +registeredUserUsingInviteCode: "Invitație folosita de" +waitingForMailAuth: "Verificarea e-mailului este în așteptare" +inviteCodeCreator: "Invitație creată de" +usedAt: "Folosit în" +unused: "Neutilizat" +used: "Utilizat" +expired: "Expirat" +doYouAgree: "De-acord?" +beSureToReadThisAsItIsImportant: "Te rugăm citește informația aceasta importantă" +iHaveReadXCarefullyAndAgree: "Am citit textul „{x}” și sunt de acord." +dialog: "Dialog" icon: "Avatar" +forYou: "Pentru tine" +currentAnnouncements: "Anunțuri curente" +pastAnnouncements: "Anunțuri anterioare" +youHaveUnreadAnnouncements: "Sunt anunțuri necitite." +useSecurityKey: "Te rugăm să urmezi instrucțiunile browserului sau ale dispozitivului tău pentru a-ți folosi cheia de securitate sau de acces." replies: "Răspunde" -renotes: "Re-notează" +renotes: "Re-Note" +loadReplies: "Afișează răspunsurile" +loadConversation: "Afișează conversația" +pinnedList: "Lista fixată" +keepScreenOn: "Menține ecranul aprins" +verifiedLink: "Deținerea linkului a fost verificată" +notifyNotes: "Notifică-mă despre notele noi" +unnotifyNotes: "Nu mai mă notifica despre notele noi" +authentication: "Autentificare" +authenticationRequiredToContinue: "Te rugăm să te autentifici pentru a continua" +dateAndTime: "Data și ora" +showRenotes: "Afiseaza Re-Notele" +edited: "Editat" +notificationRecieveConfig: "Setări de notificare" +mutualFollow: "Vă urmăriți" +followingOrFollower: "Urmărit sau urmăritor" +fileAttachedOnly: "Numai Note cu fișiere" +showRepliesToOthersInTimeline: "Afișează răspunsurile către ceilalți în cronologie" +hideRepliesToOthersInTimeline: "Ascunde răspunsurile către ceilalți în cronologie" +showRepliesToOthersInTimelineAll: "Afișează răspunsurile către ceilalți de către cei ce ii urmărești în cronologie" +repositoryUrlDescription: "Dacă utilizați Misskey așa cum este (fără modificări ale codului sursă), introduceți https://github.com/misskey-dev/misskey" +flip: "Invers" +copyReplayData: "Copiază datele de reluare" +lastNDays: "Ultimele {n} zile" +surrender: "Anulează" +copyPreferenceId: "Copiază ID-ul preferințelor" +information: "Despre" +_chat: + invitations: "Invită" + noHistory: "Nu există istoric" + members: "Membri" + home: "Acasă" + send: "Trimite" +_accountSettings: + requireSigninToViewContentsDescription2: "Conținutul nu va fi afișat în previzualizările URL (OGP), încorporate în paginile web sau pe serverele care nu acceptă citările de note." + makeNotesFollowersOnlyBefore: "Face ca notele anterioare pentru a fi afișate numai pentru urmăritori" _delivery: stop: "Suspendat" _type: none: "Publicare" +_initialTutorial: + _note: + reply: "Face clic pe acest buton pentru a răspunde la un mesaj. De asemenea, este posibil să răspunzi la răspunsuri, continuând conversația ca pe un șir de replici(thread)." + menu: "Poți vedea detaliile ce țin de Note, să copiezi linkuri și să efectuezi alte acțiuni." + _timeline: + social: "Vor fi afișate notele din cronologia „Acasă'' și „Locală''." + _postNote: + _visibility: + localOnly: "Postarea cu acest flag nu va federa nota pe alte servere. Utilizatorii de pe alte servere nu vor putea vizualiza aceste note direct, indiferent de setările de afișare de mai sus." + _cw: + description: "În locul corpului, va fi afișat conținutul scris în câmpul „comentarii”. Apăsând „citește mai mult” va dezvălui corpul." + useCases: "Acesta este folosit atunci când respectați instrucțiunile serverului, pentru notele necesare sau pentru auto-restrângerea spoilerului sau a textului sensibil." +_timelineDescription: + social: "Cronologia socială afișează note atât din cronologia de ,,Acasă'', cât și din cea ,,Locală\"." _role: + assignTarget: "Asignează" + priority: "Prioritate" _priority: + low: "Scăzuta" middle: "Mediu" + high: "Ridicată" + _options: + canManageCustomEmojis: "Gestionează emoji-uri personalizate" + canManageAvatarDecorations: "Gestionați decorațiunile avatarului" +_ffVisibility: + public: "Publică" +_ad: + back: "Înapoi" +_gallery: + my: "Galeria mea" + liked: "Postări apreciate" + like: "Îmi place!" + unlike: "Îmi displace" _email: _follow: - title: "te-a urmărit" + title: "Ai un nou urmăritor" +_instanceMute: + instanceMuteDescription: "Aceasta va dezactiva orice notă/renotă din instanțele enumerate, inclusiv cele ale utilizatorilor care răspund unui utilizator dintr-o instanță mută." _theme: description: "Descriere" keys: + fg: "Text" mention: "Mențiune" - renote: "Re-notează" + renote: "Re-Notează" divider: "Separator" + toastFg: "Textul din notificare" + fgHighlighted: "Textul evidențiat" _sfx: note: "Note" notification: "Notificări" @@ -669,6 +1284,11 @@ _ago: invalid: "Nu e nimic de văzut aici" _2fa: renewTOTPCancel: "Nu, mulțumesc." +_permissions: + "read:gallery": "Vizualizează-ți galeria" + "write:gallery": "Editează-ți galeria" + "read:gallery-likes": "Vizualizează-ți lista de postări apreciate din galerie" + "write:gallery-likes": "Editează-ți lista de postări apreciate din galerie" _widgets: profile: "Profil" instanceInfo: "Informații despre instanță" @@ -684,10 +1304,22 @@ _cw: _visibility: home: "Acasă" followers: "Urmăritori" +_postForm: + replyPlaceholder: "Răspunde la această notă..." + quotePlaceholder: "Citează aceasta nota..." + channelPlaceholder: "Postează pe un canal..." + _placeholders: + a: "Ce mai faci?" + b: "Ce se mai petrece in jurul tău?" + c: "La ce te gândești?" + d: "Ce vrei să scrii?" + e: "Începe să scrii..." + f: "Te aștept să scrii..." _profile: name: "Nume" username: "Nume de utilizator" _exportOrImport: + clips: "Clip" followingList: "Urmărești" muteList: "Amuțește" blockingList: "Blochează" @@ -696,24 +1328,28 @@ _charts: federation: "Federație" _timelines: home: "Acasă" + local: "Local" + social: "Social" + global: "Global" _play: script: "Script" summary: "Descriere" _pages: blocks: + text: "Text" image: "Imagini" _notification: youWereFollowed: "te-a urmărit" _types: follow: "Urmărești" mention: "Mențiune" - renote: "Re-notează" + renote: "Re-Note" quote: "Citează" reaction: "Reacție" login: "Autentifică-te" _actions: reply: "Răspunde" - renote: "Re-notează" + renote: "Re-Notează" _deck: _columns: notifications: "Notificări" @@ -722,8 +1358,10 @@ _deck: list: "Liste" channel: "Canale" mentions: "Mențiuni" + roleTimeline: "Cronologia rolului" _webhookSettings: name: "Nume" + active: "Activat" _abuseReport: _notificationRecipient: _recipientType: @@ -731,8 +1369,27 @@ _abuseReport: _moderationLogTypes: suspend: "Suspendă" resetPassword: "Resetează parola" + createInvitation: "Generează invitația" + deleteGalleryPost: "Postarea din galerie a fost ștearsă" +_dataSaver: + _code: + title: "Evidențierea codului" + description: "Dacă notațiile de evidențiere a codului sunt utilizate în MFM etc., acestea nu se vor încărca până când sunt atinse. Evidențierea de sintaxă necesită descărcarea fișierelor de definiție de evidențiere pentru fiecare limbaj de programare. Prin urmare, dezactivarea încărcării automate a acestor fișiere este de așteptat să reducă cantitatea de date de comunicare." _reversi: total: "Total" +_contextMenu: + app: "Aplicație" + appWithShift: "Aplicatie ce utilizeaza tasta ,,shift\"" + native: "Nativ" +_customEmojisManager: + _gridCommon: + copySelectionRows: "Copiază rândurile selectate" + copySelectionRanges: "Copiază selecția" _remoteLookupErrors: _noSuchObject: title: "Nu a fost găsit" +_search: + searchScopeAll: "Tot" + searchScopeLocal: "Local" + searchScopeUser: "Utilizator specific" + serverHostPlaceholder: "Exemplu: misskey.example.com" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 7ed41a9c47..e81af534e7 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -282,7 +282,6 @@ deleteAreYouSure: "Хотите удалить «{x}»?" resetAreYouSure: "На самом деле сбросить?" areYouSure: "Вы уверены?" saved: "Сохранено" -messaging: "Сообщения" upload: "Загрузить" keepOriginalUploading: "Сохранить исходное изображение" keepOriginalUploadingDescription: "Сохраняет исходную версию при загрузке изображений. Если выключить, то при загрузке браузер генерирует изображение для публикации." @@ -295,7 +294,6 @@ uploadFromUrlMayTakeTime: "Загрузка может занять некото explore: "Обзор" messageRead: "Прочитали" noMoreHistory: "История закончилась" -startMessaging: "Начать общение" nUsersRead: "Прочитали {n}" agreeTo: "Я соглашаюсь с {0}" agree: "Согласен" @@ -482,8 +480,6 @@ noteOf: "Что пишет {user}" quoteAttached: "Цитата" quoteQuestion: "Хотите добавить цитату?" attachAsFileQuestion: "Текста в буфере обмена слишком много. Прикрепить как текстовый файл?" -noMessagesYet: "Пока ни одного сообщения" -newMessageExists: "Новое сообщение" onlyOneFileCanBeAttached: "К сообщению можно прикрепить только один файл" signinRequired: "Пожалуйста, войдите" signinOrContinueOnRemote: "Чтобы продолжить, вам необходимо войти в аккаунт на своём сервере или зарегистрироваться / войти в аккаунт на этом." @@ -1181,6 +1177,16 @@ keepOriginalFilenameDescription: "Если вы выключите данную alwaysConfirmFollow: "Всегда подтверждать подписку" inquiry: "Связаться" messageToFollower: "Сообщение подписчикам" +postForm: "Форма отправки" +information: "Описание" +_chat: + invitations: "Пригласить" + noHistory: "История пока пуста" + members: "Участники" + home: "Главная" + send: "Отправить" +_settings: + webhook: "Вебхук" _delivery: stop: "Заморожено" _type: @@ -1683,7 +1689,6 @@ _theme: header: "Заголовок" navBg: "Фон боковой панели" navFg: "Текст на боковой панели" - navHoverFg: "Текст на боковой панели (под указателем)" navActive: "Текст на боковой панели (активирован)" navIndicator: "Индикатор на боковой панели" link: "Ссылка" @@ -1706,11 +1711,8 @@ _theme: buttonHoverBg: "Текст кнопки" inputBorder: "Рамка поля ввода" driveFolderBg: "Фон папки «Диска»" - wallpaperOverlay: "Слой обоев" badge: "Значок" messageBg: "Фон беседы" - accentDarken: "Фон (затемнённый)" - accentLighten: "Фон (осветлённый)" fgHighlighted: "Подсвеченный текст" _sfx: note: "Заметки" @@ -1799,6 +1801,7 @@ _permissions: "read:gallery-likes": "Просмотр списка понравившегося в галерее" "write:gallery-likes": "Изменение списка понравившегося в галерее" "write:admin:reset-password": "Сбросить пароль пользователю" + "write:chat": "Писать и удалять сообщения" _auth: shareAccessTitle: "Разрешения для приложений" shareAccess: "Дать доступ для «{name}» к вашей учётной записи?" @@ -2147,3 +2150,7 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "Не найдено" +_search: + searchScopeAll: "Все" + searchScopeLocal: "Местная" + searchScopeUser: "Указанный пользователь" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 521d172671..1638fd293e 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -242,7 +242,6 @@ removeAreYouSure: "Naozaj chcete odstrániť \"{x}\"?" deleteAreYouSure: "Naozaj chcete odstrániť \"{x}\"?" resetAreYouSure: "Naozaj resetovať?" saved: "Uložené" -messaging: "Chat" upload: "Nahrať súbor" keepOriginalUploading: "Zachovať pôvodný obrázok" keepOriginalUploadingDescription: "Uloží pôvodný obrázok ako je. Ak je vypnuté, verzia pre web sa vygeneruje pri nahratí." @@ -255,7 +254,6 @@ uploadFromUrlMayTakeTime: "Nahrávanie môže nejaký čas trvať." explore: "Objavovať" messageRead: "Prečítané" noMoreHistory: "To je všetko" -startMessaging: "Začať chat" nUsersRead: "prečítané {n} používateľmi" agreeTo: "Súhlasím s {0}" agreeBelow: "Súhlasím s nasledovným" @@ -428,8 +426,6 @@ retype: "Zadajte znovu" noteOf: "Poznámky používateľa {user}" quoteAttached: "Citované" quoteQuestion: "Pripojiť ako citát?" -noMessagesYet: "Zatiaľ žiadne správy" -newMessageExists: "Máte novú správu" onlyOneFileCanBeAttached: "Ku správe môžete priložiť len jeden súbor" signinRequired: "Prihláste sa, prosím!" invitations: "Pozvať" @@ -917,6 +913,14 @@ renotes: "Preposlať" sourceCode: "Zdrojový kód" flip: "Preklopiť" lastNDays: "Posledných {n} dní" +postForm: "Napísať poznámku" +information: "Informácie" +_chat: + invitations: "Pozvať" + noHistory: "Žiadna história" + members: "Členovia" + home: "Domov" + send: "Poslať" _delivery: stop: "Zmrazené" _type: @@ -1085,7 +1089,6 @@ _theme: header: "Hlavička" navBg: "Pozadie bočného panela" navFg: "Text bočného panela" - navHoverFg: "Text bočného panela (pod kurzorom)" navActive: "Text bočného panela (aktívny)" navIndicator: "Indikátor bočného panela" link: "Odkaz" @@ -1108,11 +1111,8 @@ _theme: buttonHoverBg: "Pozadie tlačidla (pod kurzorom)" inputBorder: "Okraj vstupného poľa" driveFolderBg: "Pozadie priečinu disku" - wallpaperOverlay: "Vrstvenie pozadia" badge: "Odznak" messageBg: "Pozadie chatu" - accentDarken: "Akcent (stmavené)" - accentLighten: "Akcent (zosvetlené)" fgHighlighted: "Zvýraznený text" _sfx: note: "Poznámky" @@ -1176,6 +1176,7 @@ _permissions: "write:gallery": "Upravovať vašu galériu" "read:gallery-likes": "Vidieť zoznam obľúbených príspevkov z galérie" "write:gallery-likes": "Upraviť zoznam obľúbených príspevov z galérie" + "write:chat": "Písať alebo odstraňovať správy v chate" _auth: shareAccess: "Prajete si povoliť \"{name}\", aby mal prístup k tomuto účtu?" shareAccessAsk: "Naozaj chcete povoliť tejto aplikácii prístup k tomuto účtu?" @@ -1449,3 +1450,6 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "Nenájdené" +_search: + searchScopeAll: "Všetko" + searchScopeLocal: "Lokálne" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 5961605645..ceb02ffc4c 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -249,7 +249,6 @@ removeAreYouSure: "Är du säker att du vill radera \"{x}\"?" deleteAreYouSure: "Är du säker att du vill radera \"{x}\"?" resetAreYouSure: "Vill du återställa?" saved: "Sparad" -messaging: "Chatt" upload: "Ladda upp" keepOriginalUploading: "Behåll originalbild" keepOriginalUploadingDescription: "Sparar den originellt uppladdade bilden i sitt i befintliga skick. Om avstängd, kommer en webbversion bli genererad vid uppladdning." @@ -262,7 +261,6 @@ uploadFromUrlMayTakeTime: "Det kan ta tid tills att uppladdningen blir klar." explore: "Utforska" messageRead: "Läs" noMoreHistory: "Det finns ingen mer historik" -startMessaging: "Starta en chatt" nUsersRead: "läst av {n}" agreeTo: "Jag accepterar {0}" agree: "Överens" @@ -394,7 +392,6 @@ text: "Text" enable: "Aktivera" next: "Nästa" retype: "Ange igen" -noMessagesYet: "Inga meddelanden än" invitations: "Inbjudan" invitationCode: "Inbjudningskod" available: "Tillgängligt" @@ -562,6 +559,12 @@ inquiry: "Kontakt" tryAgain: "Försök igen senare" signinWithPasskey: "Logga in med nyckel" unknownWebAuthnKey: "Okänd nyckel" +information: "Om" +_chat: + invitations: "Inbjudan" + members: "Medlemmar" + home: "Hem" + send: "Skicka" _delivery: stop: "Suspenderad" _type: @@ -707,3 +710,5 @@ _reversi: white: "Vit" _selfXssPrevention: warning: "VARNING" +_search: + searchScopeAll: "Allt" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index ec83ba888c..6398268274 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -5,6 +5,7 @@ introMisskey: "ยินดีต้อนรับทุกคนจ้า! Mis poweredByMisskeyDescription: "{name} เป็นหนึ่งในเซิร์ฟเวอร์ของแพลตฟอร์มโอเพ่นซอร์ส Misskey" monthAndDay: "{month}/{day}" search: "ค้นหา" +reset: "รีเซ็ต" notifications: "เเจ้งเตือน" username: "ชื่อผู้ใช้" password: "รหัสผ่าน" @@ -48,6 +49,7 @@ pin: "ปักหมุด" unpin: "เลิกปักหมุด" copyContent: "คัดลอกเนื้อหา" copyLink: "คัดลอกลิงก์" +copyRemoteLink: "คัดลอกลิงค์ระยะไกล" copyLinkRenote: "คัดลอกลิงก์รีโน้ต" delete: "ลบ" deleteAndEdit: "ลบและแก้ไข" @@ -287,7 +289,6 @@ deleteAreYouSure: "ต้องการลบ “{x}” ใช่ไหม?" resetAreYouSure: "รีเซ็ตเลยไหม?" areYouSure: "แน่ใจแล้วใช่ไหมคะ?" saved: "บันทึกแล้ว" -messaging: "แชท" upload: "อัปโหลด" keepOriginalUploading: "เก็บภาพต้นฉบับ" keepOriginalUploadingDescription: "เก็บภาพต้นฉบับไว้เมื่ออัปโหลดภาพ หากปิด รูปภาพสำหรับการเผยแพร่ทางเว็บจะถูกสร้างขึ้นในเบราว์เซอร์เมื่อทำการอัปโหลด" @@ -300,7 +301,6 @@ uploadFromUrlMayTakeTime: "การอัปโหลดอาจใช้เ explore: "สำรวจ" messageRead: "อ่านแล้ว" noMoreHistory: "ไม่มีประวัติเพิ่มเติม" -startMessaging: "เริ่มการสนทนา" nUsersRead: "อ่านโดย {n}" agreeTo: "ฉันยอมรับ {0}" agree: "ยอมรับ" @@ -489,8 +489,6 @@ noteOf: "โน้ตของ {user}" quoteAttached: "อ้างอิง" quoteQuestion: "ต้องการที่จะแนบมันเพื่ออ้างอิงใช่ไหม?" attachAsFileQuestion: "ข้อความในคลิปบอร์ดยาวเกินไป คุณต้องการแนบเป็นไฟล์ข้อความหรือไม่?" -noMessagesYet: "ยังไม่มีข้อความ" -newMessageExists: "คุณมีข้อความใหม่" onlyOneFileCanBeAttached: "สามารถแนบไฟล์ได้เพียงไฟล์เดียวต่อ 1 ข้อความ" signinRequired: "ก่อนดำเนินการต่อ กรุณาลงทะเบียนหรือเข้าสู่ระบบ" signinOrContinueOnRemote: "เพื่อดำเนินการต่อได้ คุณต้องไปที่เซิร์ฟเวอร์ที่คุณใช้งานอยู่ หรือลงทะเบียน/เข้าสู่ระบบเซิร์ฟเวอร์นี้" @@ -684,10 +682,12 @@ smtpSecureInfo: "ปิดสิ่งนี้เมื่อใช้ STARTTLS testEmail: "ทดสอบการส่งอีเมล" wordMute: "ปิดเสียงคำ" hardWordMute: "ปิดเสียงคำแบบแข็งโป๊ก" +hardWordMuteDescription: "ซ่อนหมายเหตุที่มีวลีที่ระบุ ต่างจากการปิดเสียงคำ โน้ตต่างๆ จะถูกซ่อนไว้อย่างสมบูรณ์" regexpError: "เกิดข้อผิดพลาดใน regular expression" regexpErrorDescription: "เกิดข้อผิดพลาดใน regular expression บรรทัดที่ {line} ของการปิดเสียงคำ {tab} :" instanceMute: "ปิดเสียงเซิร์ฟเวอร์" userSaysSomething: "{name} พูดอะไรบางอย่าง" +userSaysSomethingAbout: "{name} พูดอะไรบางอย่างเกี่ยวกับ \"{word}\"" makeActive: "เปิดใช้งาน" display: "แสดงผล" copy: "คัดลอก" @@ -1292,6 +1292,27 @@ prohibitedWordsForNameOfUser: "คำนี้ไม่สามารถใช prohibitedWordsForNameOfUserDescription: "หากมีสตริงใดๆ ในรายการนี้ปรากฏอยู่ในชื่อของผู้ใช้ ชื่อนั้นจะถูกปฏิเสธ ผู้ใช้ที่มีสิทธิ์แต่ผู้ดูแลระบบนั้นจะไม่ได้รับผลกระทบใดๆจากข้อจำกัดนี้ค่ะ" yourNameContainsProhibitedWords: "ชื่อของคุณนั้นมีคำที่ต้องห้าม" yourNameContainsProhibitedWordsDescription: "ถ้าหากคุณต้องการใช้ชื่อนี้ กรุณาติดต่อผู้ดูแลระบบของเซิร์ฟเวอร์นะค่ะ" +federationDisabled: "เซิร์ฟเวอร์นี้ปิดการใช้งานการรวมกลุ่ม คุณไม่สามารถโต้ตอบกับผู้ใช้บนเซิร์ฟเวอร์อื่นได้" +reactAreYouSure: "คุณต้องการที่จะตอบสนองต่อ \" {emoji}\" หรือไม่?" +markAsSensitiveConfirm: "คุณต้องการทำเครื่องหมายสื่อนี้ว่าละเอียดอ่อนหรือไม่?" +unmarkAsSensitiveConfirm: "คุณต้องการลบการกำหนดความไวของสื่อนี้หรือไม่?" +postForm: "แบบฟอร์มการโพสต์" +information: "เกี่ยวกับ" +right: "ขวา" +bottom: "ภายใต้" +_chat: + invitations: "คำเชิญ" + noHistory: "ไม่มีประวัติ" + members: "สมาชิก" + home: "หน้าหลัก" + send: "ส่ง" +_settings: + webhook: "Webhook" +_accountSettings: + requireSigninToViewContents: "ต้องเข้าสู่ระบบเพื่อดูเนื้อหา" + requireSigninToViewContentsDescription1: "ต้องเข้าสู่ระบบเพื่อดูบันทึกและเนื้อหาอื่น ๆ ทั้งหมดที่คุณสร้าง คาดว่าจะมีประสิทธิผลในการป้องกันไม่ให้ข้อมูลถูกเก็บรวบรวมโดยโปรแกรมรวบรวมข้อมูล" + requireSigninToViewContentsDescription2: "นอกจากนี้ จะไม่สามารถดูจากเซิร์ฟเวอร์ที่ไม่รองรับการดูตัวอย่าง URL (OGP), การฝังในหน้าเว็บ หรือการอ้างอิงหมายเหตุได้" + requireSigninToViewContentsDescription3: "เนื้อหาที่ถูกรวมเข้ากับเซิร์ฟเวอร์ระยะไกลอาจไม่อยู่ภายใต้ข้อจำกัดเหล่านี้" _abuseUserReport: forward: "ส่ง​ต่อ" forwardDescription: "ส่งรายงานไปยังเซิร์ฟเวอร์ระยะไกลโดยใช้บัญชีระบบที่ไม่ระบุตัวตน" @@ -1968,7 +1989,6 @@ _theme: header: "ส่วนหัว" navBg: "พื้นหลังแถบด้านข้าง" navFg: "ข้อความแถบด้านข้าง" - navHoverFg: "ข้อความแถบด้านข้าง (โฮเวอร์)" navActive: "ข้อความแถบด้านข้าง (ใช้งานอยู่)" navIndicator: "ตัวระบุแถบด้านข้าง" link: "ลิงก์" @@ -1991,11 +2011,8 @@ _theme: buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)" inputBorder: "เส้นขอบของช่องป้อนข้อมูล" driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์" - wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ" badge: "ตรา" messageBg: "พื้นหลังแชท" - accentDarken: "สีหลัก (มืด)" - accentLighten: "สีหลัก (สว่าง)" fgHighlighted: "ข้อความที่ไฮไลต์" _sfx: note: "โน้ต" @@ -2148,6 +2165,7 @@ _permissions: "read:clip-favorite": "ดูคลิปที่ถูกใจ" "read:federation": "รับข้อมูลเกี่ยวกับสหพันธ์" "write:report-abuse": "รายงานการละเมิด" + "write:chat": "เขียนหรือลบข้อความแชท" _auth: shareAccessTitle: "การให้สิทธิ์แอปพลิเคชัน" shareAccess: "คุณต้องการอนุญาตให้ \"{name}\" เข้าถึงบัญชีนี้เลยมั้ย?" @@ -2569,10 +2587,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "โปรดตรวจสอบให้แน่ใจว่าแหล่งแจกหน่ายมีความน่าเชื่อถือก่อนทำการติดตั้ง" _plugin: title: "ต้องการติดตั้งปลั๊กอินนี้ใช่ไหม?" - metaTitle: "ข้อมูลส่วนเสริม" _theme: title: "ต้องการติดตั้งธีมนี้ใช่ไหม?" - metaTitle: "ข้อมูลธีม" _meta: base: "โทนสีพื้นฐาน" _vendorInfo: @@ -2709,3 +2725,7 @@ _embedCodeGen: _remoteLookupErrors: _noSuchObject: title: "ไม่พบหน้าที่ต้องการ" +_search: + searchScopeAll: "ทั้งหมด" + searchScopeLocal: "ท้องถิ่น" + searchScopeUser: "ผู้ใช้เฉพาะ" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index 2c63f15aa2..1756e8dbeb 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -261,7 +261,6 @@ removeAreYouSure: "\"{x}\" silmek istediğinizden emin misiniz?" deleteAreYouSure: "\"{x}\" silmek istediğinizden emin misiniz?" resetAreYouSure: "Sıfırlansın mı?" saved: "Kaydedildi" -messaging: "Mesajlar" upload: "Yükle" keepOriginalUploading: "Orijinal görseli koru" keepOriginalUploadingDescription: "Orijinal olarak yüklenen görüntüyü olduğu gibi kaydeder. Kapatılırsa, yükleme sırasında web'de görüntülenecek bir sürüm oluşturulur." @@ -274,7 +273,6 @@ uploadFromUrlMayTakeTime: "Yüklemenin tamamlanması biraz süre alabilir." explore: "Keşfet" messageRead: "Okundu" noMoreHistory: "Bundan öncesi yok" -startMessaging: "Yeni bir sohbet başlat" nUsersRead: "{n} kişi okudu" agreeTo: "Kabul Ediyorum: {0}" agree: "Kabul Et" @@ -351,7 +349,6 @@ pinnedNotes: "Sabitlenen" manageAntennas: "Anten ayarları" userList: "Listeler" resetPassword: "Şifre sıfırlama" -noMessagesYet: "Şimdilik mesaj yok" details: "Detaylar" deck: "Güverte" smtpHost: "Sağlayıcı" @@ -378,6 +375,8 @@ addMemo: "Kısa not ekle" icon: "Avatar" replies: "yanıt" renotes: "vazgeçme" +_chat: + home: "Ana sayfa" _delivery: stop: "Askıya alınmış" _type: @@ -460,3 +459,5 @@ _deck: _moderationLogTypes: suspend: "askıya al" resetPassword: "Şifre sıfırlama" +_search: + searchScopeAll: "Tümü" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index a83ad80683..6a970fadfb 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -246,7 +246,6 @@ removeAreYouSure: "Ви впевнені, що хочете видалити \"{ deleteAreYouSure: "Ви впевнені, що хочете видалити \"{x}\"?" resetAreYouSure: "Справді скинути?" saved: "Збережено" -messaging: "Чати" upload: "Завантажити" keepOriginalUploading: "Зберегти оригінальне зображення" keepOriginalUploadingDescription: "Зберігає початково завантажене зображення як є. Якщо вимкнено, версія для відображення в Інтернеті буде створена під час завантаження." @@ -259,7 +258,6 @@ uploadFromUrlMayTakeTime: "Завантаження може зайняти де explore: "Огляд" messageRead: "Прочитано" noMoreHistory: "Подальшої історії немає" -startMessaging: "Розпочати діалог" nUsersRead: "Прочитали {n}" agreeTo: "Я погоджуюсь з {0}" agreeBelow: "Я погоджуюся з наведеним нижче" @@ -427,8 +425,6 @@ retype: "Введіть ще раз" noteOf: "Нотатка {user}" quoteAttached: "Цитата" quoteQuestion: "Ви хочете додати цитату?" -noMessagesYet: "Ще немає повідомлень" -newMessageExists: "Є нові повідомлення" onlyOneFileCanBeAttached: "До повідомлення можна вкласти лише один файл" signinRequired: "Будь ласка, авторизуйтесь" invitations: "Запрошення" @@ -909,6 +905,14 @@ renotes: "Поширити" sourceCode: "Вихідний код" flip: "Перевернути" lastNDays: "Останні {n} днів" +postForm: "Створення нотатки" +information: "Інформація" +_chat: + invitations: "Запросити" + noHistory: "Історія порожня" + members: "Учасники" + home: "Домівка" + send: "Відправити" _delivery: stop: "Призупинено" _type: @@ -1279,7 +1283,6 @@ _theme: header: "Заголовок" navBg: "Фон бокової панелі" navFg: "Текст бокової панелі" - navHoverFg: "Текст бокової панелі (під курсором)" navActive: "Текст бокової панелі (активне)" navIndicator: "Індикатор бокової панелі" link: "Посилання" @@ -1302,11 +1305,8 @@ _theme: buttonHoverBg: "Фон кнопки (при наведенні)" inputBorder: "Край поля вводу" driveFolderBg: "Фон папки на диску" - wallpaperOverlay: "Накладання шпалер" badge: "Значок" messageBg: "Фон переписки" - accentDarken: "Акцент (Затемлений)" - accentLighten: "Акцент (Освітлений)" fgHighlighted: "Виділений текст" _sfx: note: "Нотатки" @@ -1365,6 +1365,7 @@ _permissions: "read:channels": "Переглядати канали" "write:channels": "Змінювати канали" "read:gallery": "Перегляд галереї" + "write:chat": "Створювати та видаляти повідомлення" _auth: shareAccess: "Ви хочете надати \"{name}\" доступ до цього акаунту?" shareAccessAsk: "Ви впевнені, що хочете надати цій програмі доступ до вашого акаунту?" @@ -1624,3 +1625,6 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "Не знайдено" +_search: + searchScopeAll: "Всі" + searchScopeLocal: "Локальна" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index 6015492b92..8289a6d60c 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -257,7 +257,6 @@ removeAreYouSure: "“{x}”ni olib tashlamoqchi ekanligingizga ishonchingiz kom deleteAreYouSure: "“{x}”ni chindan ham yo'q qilmoqchimisiz?" resetAreYouSure: "Haqiqatan ham qayta tiklansinmi?" saved: "Saqlandi" -messaging: "Suhbat" upload: "Yuklash" keepOriginalUploading: "Asl rasmni saqlang" keepOriginalUploadingDescription: "Rasmlarni yuklashda asl nusxasini saqlaydi. Agar o'chirilgan bo'lsa, brauzer yuklangandan keyin nashr qilish uchun rasm yaratadi." @@ -270,7 +269,6 @@ uploadFromUrlMayTakeTime: "Yuklash tugallanishi uchun biroz vaqt ketishi mumkin. explore: "Ko'rib chiqish" messageRead: "O‘qildi" noMoreHistory: "Buning ortida hech qanday hikoya yo'q" -startMessaging: "Yangi suhbatni boshlash" nUsersRead: "{n} tomonidan o'qildi" agreeTo: "Men {0} ga roziman" agree: "Rozi bo'lish" @@ -445,8 +443,6 @@ retype: "Qayta kiriting" noteOf: "{user} tomonidan joylandi\n" quoteAttached: "Iqtibos" quoteQuestion: "Iqtibos sifatida qo'shilsinmi?" -noMessagesYet: "Bu yerda xabarlar yo'q" -newMessageExists: "Yangi xabarlar bor" onlyOneFileCanBeAttached: "Faqat bitta faylni biriktirish mumkin" signinRequired: "Davom etishdan oldin ro'yhatdan o'tishingiz yoki tizimga kirishingiz kerak" invitations: "Taklif qilish" @@ -841,6 +837,13 @@ icon: "Avatar" replies: "Javob berish" renotes: "Qayta qayd etish" flip: "Teskari" +information: "Haqida" +_chat: + invitations: "Taklif qilish" + noHistory: "Tarix yo'q" + members: "A'zolar" + home: "Bosh sahifa" + send: "Yuborish" _delivery: stop: "To'xtatilgan" _type: @@ -904,8 +907,6 @@ _theme: mention: "Murojat" renote: "Qayta qayd etish" divider: "Ajratrmoq" - accentDarken: "Urg'u (Qoraytirilgan)" - accentLighten: "Urg'u (Yoritilgan)" fgHighlighted: "Belgilangan matn" _sfx: note: "Qaydlar" @@ -1094,3 +1095,6 @@ _reversi: _remoteLookupErrors: _noSuchObject: title: "Topilmadi" +_search: + searchScopeAll: "Barcha" + searchScopeLocal: "Mahalliy" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index e6a9418126..525c3c233f 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1,10 +1,11 @@ --- -_lang_: "Tiếng Nhật" +_lang_: "Tiếng Việt " headlineMisskey: "Mạng xã hội liên hợp" introMisskey: "Xin chào! Misskey là một nền tảng tiểu blog phi tập trung mã nguồn mở.\nViết \"tút\" để chia sẻ những suy nghĩ của bạn 📡\nBằng \"biểu cảm\", bạn có thể bày tỏ nhanh chóng cảm xúc của bạn với các tút 👍\nHãy khám phá một thế giới mới! 🚀" poweredByMisskeyDescription: "{name} là một trong những chủ máy của Misskey là nền tảng mã nguồn mở" monthAndDay: "{day} tháng {month}" search: "Tìm kiếm" +reset: "cài lại" notifications: "Thông báo" username: "Tên người dùng" password: "Mật khẩu" @@ -48,9 +49,10 @@ pin: "Ghim" unpin: "Bỏ ghim" copyContent: "Chép nội dung" copyLink: "Chép liên kết" +copyRemoteLink: "Sao chép liên kết từ xa" copyLinkRenote: "Sao chép liên kết ghi chú" delete: "Xóa" -deleteAndEdit: "Sửa" +deleteAndEdit: "Xóa và soạn thảo lại" deleteAndEditConfirm: "Bạn có chắc muốn sửa tút này? Những biểu cảm, lượt trả lời và đăng lại sẽ bị mất." addToList: "Thêm vào danh sách" addToAntenna: "Thêm vào Ăngten" @@ -63,6 +65,7 @@ copyFileId: "Sao chép ID tập tin" copyFolderId: "Sao chép ID thư mục" copyProfileUrl: "Sao chép URL hồ sơ" searchUser: "Tìm kiếm người dùng" +searchThisUsersNotes: "Tìm kiếm ghi chú của người dùng" reply: "Trả lời" loadMore: "Tải thêm" showMore: "Xem thêm" @@ -111,11 +114,14 @@ enterEmoji: "Chèn emoji" renote: "Đăng lại" unrenote: "Hủy đăng lại" renoted: "Đã đăng lại." +renotedToX: "Đã cho thuê lại {name}." cantRenote: "Không thể đăng lại tút này." cantReRenote: "Không thể đăng lại một tút đăng lại." quote: "Trích dẫn" inChannelRenote: "Chia sẻ trong kênh này" inChannelQuote: "Trích dẫn trong kênh này" +renoteToChannel: "Đăng lại tới kênh" +renoteToOtherChannel: "Đăng lại tới kênh khác" pinnedNote: "Bài viết đã ghim" pinned: "Ghim" you: "Bạn" @@ -125,6 +131,11 @@ add: "Thêm" reaction: "Biểu cảm" reactions: "Biểu cảm" emojiPicker: "Bộ chọn biểu tượng cảm xúc" +pinnedEmojisForReactionSettingDescription: "Ghim các biểu tượng cảm xúc sẽ hiển thị khi phản hồi" +pinnedEmojisSettingDescription: "Ghim các biểu tượng cảm xúc sẽ hiển thị trong bảng chọn emoji" +emojiPickerDisplay: "Hiển thị bộ chọn" +overwriteFromPinnedEmojisForReaction: "Ghi đè thiết lập phản hồi" +overwriteFromPinnedEmojis: "Ghi đè thiết lập chung" reactionSettingDescription2: "Kéo để sắp xếp, nhấn để xóa, nhấn \"+\" để thêm." rememberNoteVisibility: "Lưu kiểu tút mặc định" attachCancel: "Gỡ tập tin đính kèm" @@ -149,6 +160,7 @@ editList: "Chỉnh sửa danh sách" selectChannel: "Lựa chọn kênh" selectAntenna: "Chọn một antenna" editAntenna: "Chỉnh sửa Ăngten" +createAntenna: "Tạo Ăngten " selectWidget: "Chọn tiện ích" editWidgets: "Sửa tiện ích" editWidgetsExit: "Xong" @@ -175,6 +187,10 @@ addAccount: "Thêm tài khoản" reloadAccountsList: "Cập nhật danh sách tài khoản" loginFailed: "Đăng nhập không thành công" showOnRemote: "Truy cập trang của người này" +continueOnRemote: "Tiếp tục trên phiên bản từ xa" +chooseServerOnMisskeyHub: "Chọn một máy chủ từ Misskey Hub" +specifyServerHost: "Thiết lập một máy chủ" +inputHostName: "Nhập địa chỉ máy chủ" general: "Tổng quan" wallpaper: "Ảnh bìa" setWallpaper: "Đặt ảnh bìa" @@ -185,6 +201,7 @@ followConfirm: "Bạn theo dõi {name}?" proxyAccount: "Tài khoản proxy" proxyAccountDescription: "Tài khoản proxy là tài khoản hoạt động như một người theo dõi từ xa cho người dùng trong những điều kiện nhất định. Ví dụ: khi người dùng thêm người dùng từ xa vào danh sách, hoạt động của người dùng từ xa sẽ không được chuyển đến phiên bản nếu không có người dùng cục bộ nào theo dõi người dùng đó, vì vậy tài khoản proxy sẽ theo dõi." host: "Host" +selectSelf: "Chọn chính bạn" selectUser: "Chọn người dùng" recipient: "Người nhận" annotation: "Bình luận" @@ -199,6 +216,8 @@ perHour: "Mỗi Giờ" perDay: "Mỗi Ngày" stopActivityDelivery: "Ngưng gửi hoạt động" blockThisInstance: "Chặn máy chủ này" +silenceThisInstance: "Máy chủ im lặng" +mediaSilenceThisInstance: "Tắt nội dung đa phương tiện từ máy chủ này" operations: "Vận hành" software: "Phần mềm" version: "Phiên bản" @@ -218,6 +237,12 @@ clearCachedFiles: "Xóa bộ nhớ đệm" clearCachedFilesConfirm: "Bạn có chắc muốn xóa sạch bộ nhớ đệm?" blockedInstances: "Máy chủ đã chặn" blockedInstancesDescription: "Danh sách những máy chủ bạn muốn chặn. Chúng sẽ không thể giao tiếp với máy chủy này nữa." +silencedInstances: "Máy chủ im lặng" +silencedInstancesDescription: "Đặt máy chủ mà bạn muốn tắt tiếng, phân tách bằng dấu xuống dòng. Tất cả tài khoản trên máy chủ bị tắt tiếng sẽ được coi là \"bị tắt tiếng\" và mọi hành động theo dõi sẽ được coi là yêu cầu. Không có tác dụng với những trường hợp bị chặn." +mediaSilencedInstances: "Các máy chủ đã tắt nội dung đa phương tiện " +mediaSilencedInstancesDescription: "Đặt máy chủ mà bạn muốn tắt nội dung đa phương tiện, phân tách bằng dấu xuống dòng. Tất cả tài khoản trên máy chủ bị tắt tiếng sẽ được coi là \"nhạy cảm\" và biểu tượng cảm xúc tùy chỉnh sẽ không thể được sử dụng. Không có tác dụng với những trường hợp bị chặn." +federationAllowedHosts: "Các máy chủ được phép liên kết" +federationAllowedHostsDescription: "Điền tên các máy chủ mà bạn muốn cho phép liên kết, cách nhau bởi dấu xuống dòng" muteAndBlock: "Ẩn và Chặn" mutedUsers: "Người đã ẩn" blockedUsers: "Người đã chặn" @@ -254,8 +279,8 @@ more: "Thêm nữa!" featured: "Nổi bật" usernameOrUserId: "Tên người dùng hoặc ID" noSuchUser: "Không tìm thấy người dùng" -lookup: "Tìm kiếm" -announcements: "Thông báo" +lookup: "Tra cứu" +announcements: "Thông báo máy chủ" imageUrl: "URL ảnh" remove: "Xóa" removed: "Đã xóa" @@ -264,7 +289,6 @@ deleteAreYouSure: "Bạn có chắc muốn xóa \"{x}\"?" resetAreYouSure: "Bạn có chắc muốn đặt lại?" areYouSure: "Bạn chắc chứ?" saved: "Đã lưu" -messaging: "Trò chuyện" upload: "Tải lên" keepOriginalUploading: "Giữ hình ảnh gốc" keepOriginalUploadingDescription: "Giữ nguyên như hình ảnh được tải lên ban đầu. Nếu tắt, một phiên bản để hiển thị trên web sẽ được tạo khi tải lên." @@ -277,7 +301,7 @@ uploadFromUrlMayTakeTime: "Sẽ mất một khoảng thời gian để tải lê explore: "Khám phá" messageRead: "Đã đọc" noMoreHistory: "Không còn gì để đọc" -startMessaging: "Bắt đầu trò chuyện" +startChat: "Bắt đầu trò chuyện" nUsersRead: "đọc bởi {n}" agreeTo: "Tôi đồng ý {0}" agree: "Đồng ý" @@ -308,6 +332,7 @@ selectFile: "Chọn tập tin" selectFiles: "Chọn nhiều tập tin" selectFolder: "Chọn thư mục" selectFolders: "Chọn nhiều thư mục" +fileNotSelected: "Chưa chọn tệp nào" renameFile: "Đổi tên tập tin" folderName: "Tên thư mục" createFolder: "Tạo thư mục" @@ -315,6 +340,7 @@ renameFolder: "Đổi tên thư mục" deleteFolder: "Xóa thư mục" folder: "Thư mục" addFile: "Thêm tập tin" +showFile: "Hiển thị tập tin" emptyDrive: "Ổ đĩa của bạn trống trơn" emptyFolder: "Thư mục trống" unableToDelete: "Không thể xóa" @@ -398,6 +424,7 @@ antennaExcludeBots: "Loại trừ các tài khoản bot" antennaKeywordsDescription: "Phân cách bằng dấu cách cho điều kiện AND hoặc bằng xuống dòng cho điều kiện OR." notifyAntenna: "Thông báo có tút mới" withFileAntenna: "Chỉ những tút có media" +excludeNotesInSensitiveChannel: "Không hiển thị trong kênh nhạy cảm" enableServiceworker: "Bật ServiceWorker" antennaUsersDescription: "Liệt kê mỗi hàng một tên người dùng" caseSensitive: "Trường hợp nhạy cảm" @@ -428,6 +455,7 @@ totpDescription: "Nhắn mã OTP bằng ứng dụng xác thực" moderator: "Kiểm duyệt viên" moderation: "Kiểm duyệt" moderationNote: "Ghi chú kiểm duyệt" +moderationNoteDescription: "Bạn có thể điền vào những ghi chú chỉ được chia sẻ giữa những người kiểm duyệt." addModerationNote: "Thêm ghi chú kiểm duyệt" moderationLogs: "Nhật kí quản trị" nUsersMentioned: "Dùng bởi {n} người" @@ -463,10 +491,9 @@ noteOf: "Tút của {user}" quoteAttached: "Trích dẫn" quoteQuestion: "Trích dẫn lại?" attachAsFileQuestion: "Văn bản ở trong bộ nhớ tạm rất dài. Bạn có muốn đăng nó dưới dạng một tệp văn bản không?" -noMessagesYet: "Chưa có tin nhắn" -newMessageExists: "Bạn có tin nhắn mới" onlyOneFileCanBeAttached: "Bạn chỉ có thể đính kèm một tập tin" signinRequired: "Vui lòng đăng nhập" +signinOrContinueOnRemote: "Để tiếp tục, bạn cần chuyển máy chủ hoặc đăng nhập/đăng ký ở máy chủ này." invitations: "Mời" invitationCode: "Mã mời" checking: "Đang kiểm tra..." @@ -488,7 +515,12 @@ uiLanguage: "Ngôn ngữ giao diện" aboutX: "Giới thiệu {x}" emojiStyle: "Kiểu cách Emoji" native: "Bản xứ" +menuStyle: "Kiểu Menu" +style: "Phong cách" +drawer: "Ngăn ứng dụng" +popup: "Cửa sổ bật lên" showNoteActionsOnlyHover: "Chỉ hiển thị các hành động ghi chú khi di chuột" +showReactionsCount: "Hiển thị số reaction trong bài đăng" noHistory: "Không có dữ liệu" signinHistory: "Lịch sử đăng nhập" enableAdvancedMfm: "Xem bài MFM chất lượng cao." @@ -501,6 +533,7 @@ createAccount: "Tạo tài khoản" existingAccount: "Tài khoản hiện có" regenerate: "Tạo lại" fontSize: "Cỡ chữ" +mediaListWithOneImageAppearance: "Chiều cao của danh sách nội dung đã phương tiện mà chỉ có một hình ảnh" limitTo: "Giới hạn tỷ lệ {x}" noFollowRequests: "Bạn không có yêu cầu theo dõi nào" openImageInNewTab: "Mở ảnh trong tab mới" @@ -535,10 +568,12 @@ objectStorageUseSSLDesc: "Tắt nếu bạn không dùng HTTPS để kết nối objectStorageUseProxy: "Kết nối thông qua Proxy" objectStorageUseProxyDesc: "Tắt nếu bạn không dùng Proxy để kết nối API" objectStorageSetPublicRead: "Đặt \"public-read\" khi tải lên" +s3ForcePathStyleDesc: "Nếu s3ForcePathStyle được bật, tên bucket phải được thêm vào địa chỉ URL thay vì chỉ có tên miền. Bạn có thể phải sử dụng thiết lập này nếu bạn sử dụng các dịch vụ như Minio mà bạn tự cung cấp." serverLogs: "Nhật ký máy chủ" deleteAll: "Xóa tất cả" showFixedPostForm: "Hiện khung soạn tút ở phía trên bảng tin" showFixedPostFormInChannel: "Hiển thị mẫu bài đăng ở phía trên bản tin" +withRepliesByDefaultForNewlyFollowed: "Mặc định hiển thị trả lời từ những người dùng mới theo dõi trong dòng thời gian" newNoteRecived: "Đã nhận tút mới" sounds: "Âm thanh" sound: "Âm thanh" @@ -549,7 +584,9 @@ popout: "Pop-out" volume: "Âm lượng" masterVolume: "Âm thanh chung" notUseSound: "Tắt tiếng" +useSoundOnlyWhenActive: "Chỉ phát âm thanh khi Misskey đang được hiển thị" details: "Chi tiết" +renoteDetails: "Tìm hiểu thêm về đăng lại " chooseEmoji: "Chọn emoji" unableToProcess: "Không thể hoàn tất hành động" recentUsed: "Sử dụng gần đây" @@ -565,6 +602,7 @@ ascendingOrder: "Tăng dần" descendingOrder: "Giảm dần" scratchpad: "Scratchpad" scratchpadDescription: "Scratchpad cung cấp môi trường cho các thử nghiệm AiScript. Bạn có thể viết, thực thi và kiểm tra kết quả tương tác với Misskey trong đó." +uiInspector: "Trình kiểm tra UI" output: "Nguồn ra" script: "Kịch bản" disablePagesScript: "Tắt AiScript trên Trang" @@ -623,6 +661,7 @@ medium: "Vừa" small: "Nhỏ" generateAccessToken: "Tạo mã truy cập" permission: "Cho phép " +adminPermission: "Quyền quản trị viên" enableAll: "Bật toàn bộ" disableAll: "Tắt toàn bộ" tokenRequested: "Cấp quyền truy cập vào tài khoản" @@ -644,13 +683,19 @@ smtpSecure: "Dùng SSL/TLS ngầm định cho các kết nối SMTP" smtpSecureInfo: "Tắt cái này nếu dùng STARTTLS" testEmail: "Kiểm tra vận chuyển email" wordMute: "Ẩn chữ" +wordMuteDescription: "Thu nhỏ các bài đăng chứa các từ hoặc cụm từ nhất định. Các bài đăng này có thể được hiển thị khi click vào." +hardWordMute: "Ẩn cụm từ hoàn toàn" +showMutedWord: "Hiển thị từ đã ẩn" +hardWordMuteDescription: "Ẩn hoàn toàn các bài đăng chứa từ hoặc cụm từ. Khác với mute, bài đăng sẽ bị ẩn hoàn toàn." regexpError: "Lỗi biểu thức" regexpErrorDescription: "Xảy ra lỗi biểu thức ở dòng {line} của {tab} chữ ẩn:" instanceMute: "Những máy chủ ẩn" userSaysSomething: "{name} nói gì đó" +userSaysSomethingAbout: "{name} đã nói gì đó về \"{word}\"" makeActive: "Kích hoạt" display: "Hiển thị" copy: "Sao chép" +copiedToClipboard: "Đã sao chép vào clipboard" metrics: "Số liệu" overview: "Tổng quan" logs: "Nhật ký" @@ -665,12 +710,14 @@ useGlobalSettingDesc: "Nếu được bật, cài đặt thông báo của bạn other: "Khác" regenerateLoginToken: "Tạo lại mã đăng nhập" regenerateLoginTokenDescription: "Tạo lại mã nội bộ có thể dùng để đăng nhập. Thông thường hành động này là không cần thiết. Nếu được tạo lại, tất cả các thiết bị sẽ bị đăng xuất." +theKeywordWhenSearchingForCustomEmoji: "Đây là từ khoá được sử dụng để tìm kiếm emoji" setMultipleBySeparatingWithSpace: "Tách nhiều mục nhập bằng dấu cách." fileIdOrUrl: "ID tập tin hoặc URL" behavior: "Thao tác" sample: "Ví dụ" abuseReports: "Lượt báo cáo" reportAbuse: "Báo cáo" +reportAbuseRenote: "Báo cáo bài đăng lại" reportAbuseOf: "Báo cáo {name}" fillAbuseReportDescription: "Vui lòng điền thông tin chi tiết về báo cáo này. Nếu đó là về một tút cụ thể, hãy kèm theo URL của tút." abuseReported: "Báo cáo đã được gửi. Cảm ơn bạn nhiều." @@ -720,6 +767,7 @@ lockedAccountInfo: "Ghi chú của bạn sẽ hiển thị với bất kỳ ai, alwaysMarkSensitive: "Luôn đánh dấu NSFW" loadRawImages: "Tải ảnh gốc thay vì ảnh thu nhỏ" disableShowingAnimatedImages: "Không phát ảnh động" +highlightSensitiveMedia: "Đánh dấu nội dung nhạy cảm" verificationEmailSent: "Một email xác minh đã được gửi. Vui lòng nhấn vào liên kết đính kèm để hoàn tất xác minh." notSet: "Chưa đặt" emailVerified: "Email đã được xác minh" @@ -813,6 +861,7 @@ administration: "Quản lý" accounts: "Tài khoản của bạn" switch: "Chuyển đổi" noMaintainerInformationWarning: "Chưa thiết lập thông tin vận hành." +noInquiryUrlWarning: "Địa chỉ hỏi đáp chưa được đặt" noBotProtectionWarning: "Bảo vệ Bot chưa thiết lập." configure: "Thiết lập" postToGallery: "Tạo tút có ảnh" @@ -877,6 +926,7 @@ followersVisibility: "Hiển thị người theo dõi" continueThread: "Tiếp tục xem chuỗi tút" deleteAccountConfirm: "Điều này sẽ khiến tài khoản bị xóa vĩnh viễn. Vẫn tiếp tục?" incorrectPassword: "Sai mật khẩu." +incorrectTotp: "Mã OTP không đúng hoặc đã quá hạn" voteConfirm: "Xác nhận bình chọn \"{choice}\"?" hide: "Ẩn" useDrawerReactionPickerForMobile: "Hiện bộ chọn biểu cảm dạng xổ ra trên điện thoại" @@ -901,6 +951,9 @@ oneHour: "1 giờ" oneDay: "1 ngày" oneWeek: "1 tuần" oneMonth: "1 tháng" +threeMonths: "3 tháng" +oneYear: "1 năm" +threeDays: "3 ngày " reflectMayTakeTime: "Có thể mất một thời gian để điều này được áp dụng." failedToFetchAccountInformation: "Không thể lấy thông tin tài khoản" rateLimitExceeded: "Giới hạn quá mức" @@ -925,6 +978,7 @@ document: "Tài liệu" numberOfPageCache: "Số lượng trang bộ nhớ đệm" numberOfPageCacheDescription: "Việc tăng con số này sẽ cải thiện sự thuận tiện cho người dùng nhưng gây ra nhiều áp lực hơn cho máy chủ cũng như sử dụng nhiều bộ nhớ hơn." logoutConfirm: "Bạn có chắc muốn đăng xuất?" +logoutWillClearClientData: "Đăng xuất sẽ xoá các thiết lập của bạn khỏi trình duyệt. Để có thể khôi phục thiết lập khi đăng nhập lại, bạn phải bật tự động sao lưu cài đặt." lastActiveDate: "Lần cuối vào" statusbar: "Thanh trạng thái" pleaseSelect: "Chọn một lựa chọn" @@ -974,6 +1028,7 @@ neverShow: "Không hiển thị nữa" remindMeLater: "Để sau" didYouLikeMisskey: "Bạn có ưa thích Mískey không?" pleaseDonate: "Misskey là phần mềm miễn phí mà {host} đang sử dụng. Xin mong bạn quyên góp cho chúng tôi để chúng tôi có thể tiếp tục phát triển dịch vụ này. Xin cảm ơn!!" +correspondingSourceIsAvailable: "Mã nguồn có thể được xem tại {anchor}" roles: "Vai trò" role: "Vai trò" noRole: "Bạn chưa được cấp quyền." @@ -1001,23 +1056,41 @@ thisPostMayBeAnnoyingHome: "Đăng trên trang chính" thisPostMayBeAnnoyingCancel: "Từ chối" thisPostMayBeAnnoyingIgnore: "Đăng bài để nguyên" collapseRenotes: "Không hiển thị bài viết đã từng xem" +collapseRenotesDescription: "Các bài đăng bị thu gọn mà bạn đã phản hồi hoặc đăng lại trước đây." internalServerError: "Lỗi trong chủ máy" internalServerErrorDescription: "Trong chủ máy lỗi bất ngờ xảy ra" copyErrorInfo: "Sao chép thông tin lỗi" joinThisServer: "Đăng ký trên chủ máy này" exploreOtherServers: "Tìm chủ máy khác" letsLookAtTimeline: "Thử xem Timeline" +disableFederationConfirm: "Bạn có muốn làm điều đó mà không cần liên minh không?" +disableFederationConfirmWarn: "Ngay cả khi bị trì hoãn, bài đăng vẫn sẽ tiếp tục là công khai trừ khi được thiết lập khác. Bạn thường không cần phải làm điều này." disableFederationOk: "Vô hiệu hoá" +invitationRequiredToRegister: "Phiên bản này chỉ dành cho người được mời. Bạn phải nhập mã mời hợp lệ để đăng ký." emailNotSupported: "Máy chủ này không hỗ trợ gửi email" postToTheChannel: "Đăng lên kênh" cannotBeChangedLater: "Không thể thay đổi sau này." +reactionAcceptance: "Phản ứng chấp nhận" likeOnly: "Chỉ lượt thích" +likeOnlyForRemote: "Tất cả (chỉ bao gồm lượt thích trên các máy chủ khác)" +nonSensitiveOnly: "Chỉ nội dung không nhạy cảm" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "Chỉ nội dung không nhạy cảm (chỉ bao gồm lượt thích từ máy chủ khác)" rolesAssignedToMe: "Vai trò được giao cho tôi" resetPasswordConfirm: "Bạn thực sự muốn đặt lại mật khẩu?" sensitiveWords: "Các từ nhạy cảm" +sensitiveWordsDescription: "Phạm vi của tất cả bài đăng chứa các từ được cấu hình sẽ tự động được đặt về \"Home\". Ban có thể thêm nhiều từ trên mỗi dòng." +sensitiveWordsDescription2: "Sử dụng dấu cách sẽ tạo cấu trúc AND và thêm dấu gạch xuôi để sử dụng như một regex." prohibitedWords: "Các từ bị cấm" +prohibitedWordsDescription: "Hiển thị lỗi khi đăng một bài đăng chứa các từ sau. Nhiều từ có thể được thêm bằng cách viết một từ trên mỗi dòng." +prohibitedWordsDescription2: "Sử dụng dấu cách sẽ tạo cấu trúc AND và thêm dấu gạch xuôi để sử dụng như một regex." +hiddenTags: "Hashtag ẩn" +hiddenTagsDescription: "Các hashtag này sẽ không được hiển thị trên danh sách Trending. Nhiều tag có thể được thêm bằng cách viết một tag trên mỗi dòng." +notesSearchNotAvailable: "Tìm kiếm bài đăng hiện không khả dụng." license: "Giấy phép" unfavoriteConfirm: "Bạn thực sự muốn xoá khỏi mục yêu thích?" +myClips: "Các clip của tôi" +drivecleaner: "Trình dọn đĩa" +retryAllQueuesNow: "Thử lại cho tất cả hàng chờ" retryAllQueuesConfirmTitle: "Bạn có muốn thử lại?" retryAllQueuesConfirmText: "Điều này sẽ tạm thời làm tăng mức độ tải của máy chủ." enableChartsForRemoteUser: "Tạo biểu đồ người dùng từ xa" @@ -1053,6 +1126,8 @@ options: "Tùy chọn" specifyUser: "Người dùng chỉ định" failedToPreviewUrl: "Không thể xem trước" update: "Cập nhật" +cancelReactionConfirm: "Bạn có muốn hủy phản ứng của mình không?" +changeReactionConfirm: "Bạn có muốn thay đổi phản ứng của mình không?" later: "Để sau" goToMisskey: "Tới Misskey" installed: "Đã tải xuống" @@ -1101,6 +1176,7 @@ mutualFollow: "Theo dõi lẫn nhau" followingOrFollower: "Đang theo dõi hoặc người theo dõi" externalServices: "Các dịch vụ bên ngoài" sourceCode: "Mã nguồn" +repositoryUrlDescription: "Nếu bạn có kho lưu trữ mã nguồn có thể truy cập công khai, hãy nhập URL. Nếu bạn đang sử dụng Misskey theo mặc định (không thực hiện bất kỳ thay đổi nào đối với mã nguồn), hãy nhập https://github.com/misskey-dev/misskey." feedback: "Phản hồi" feedbackUrl: "URL phản hồi" privacyPolicy: "Chính sách bảo mật" @@ -1117,8 +1193,29 @@ releaseToRefresh: "Thả để làm mới" refreshing: "Đang làm mới" pullDownToRefresh: "Kéo xuống để làm mới" cwNotationRequired: "Nếu \"Ẩn nội dung\" được bật thì cần phải có chú thích." +decorate: "Trang trí" lastNDays: "{n} ngày trước" +userSaysSomethingSensitive: "Bài đăng có chứa các tập tin nhạy cảm từ {name}" surrender: "Từ chối" +signinWithPasskey: "Đăng nhập bằng mật khẩu của bạn" +passkeyVerificationFailed: "Xác minh mật khẩu không thành công." +messageToFollower: "Tin nhắn cho người theo dõi" +yourNameContainsProhibitedWords: "Tên bạn đang cố gắng đổi có chứa chuỗi ký tự bị cấm." +yourNameContainsProhibitedWordsDescription: "Tên có chứa chuỗi ký tự bị cấm. Nếu bạn muốn sử dụng tên này, hãy liên hệ với quản trị viên máy chủ của bạn." +federationDisabled: "Liên kết bị vô hiệu hóa trên máy chủ này. Bạn không thể tương tác với người dùng trên các máy chủ khác." +reactAreYouSure: "Bạn có muốn phản hồi với \" {emoji} \" không?" +paste: "dán" +postForm: "Mẫu đăng" +information: "Giới thiệu" +_chat: + invitations: "Mời" + noHistory: "Không có dữ liệu" + members: "Thành viên" + home: "Trang chính" + send: "Gửi" +_accountSettings: + requireSigninToViewContents: "Yêu cầu đăng nhập để xem nội dung" + requireSigninToViewContentsDescription1: "Yêu cầu đăng nhập để xem tất cả ghi chú và nội dung khác mà bạn tạo. Điều này được kỳ vọng sẽ có hiệu quả trong việc ngăn chặn thông tin bị thu thập bởi các trình thu thập thông tin." _delivery: stop: "Đã vô hiệu hóa" _type: @@ -1142,8 +1239,33 @@ _initialAccountSetting: pushNotificationDescription: "Bật thông báo đẩy sẽ cho phép bạn nhận thông báo từ {name} trực tiếp từ thiết bị của bạn." initialAccountSettingCompleted: "Thiết lập tài khoản thành công!" haveFun: "Hãy tận hưởng {name} nhé!" + youCanContinueTutorial: "Bạn có thể tiếp tục xem hướng dẫn về cách sử dụng {name} (Misskey) hoặc bạn có thể thoát khỏi phần thiết lập tại đây và bắt đầu sử dụng ngay lập tức." + startTutorial: "Bắt đầu hướng dẫn" skipAreYouSure: "Bạn thực sự muốn bỏ qua mục thiết lập tài khoản?" laterAreYouSure: "Bạn thực sự muốn thiết lập tài khoản vào lúc khác?" +_initialTutorial: + launchTutorial: "Bắt đầu hướng dẫn" + title: "Hướng dẫn" + wellDone: "Làm tốt!" + skipAreYouSure: "Thoát khỏi hướng dẫn?" + _landing: + title: "Chào mừng đến với Hướng dẫn" + description: "Tại đây, bạn có thể tìm hiểu những điều cơ bản về cách sử dụng Misskey và các tính năng của nó." + _note: + title: "Bài Viết là gì?" + description: "Các bài đăng trên Misskey được gọi là 'Bài Viết'. Ghi chú được sắp xếp theo thứ tự thời gian trên dòng thời gian và được cập nhật theo thời gian thực." + _timeline: + home: "Bạn có thể xem ghi chú từ những tài khoản bạn theo dõi." + local: "Bạn có thể xem ghi chú từ tất cả người dùng trên máy chủ này." + social: "Ghi chú từ dòng thời gian Trang chủ và Địa phương sẽ được hiển thị." + global: "Bạn có thể xem ghi chú từ tất cả các máy chủ được kết nối." + _postNote: + _visibility: + home: "Chỉ công khai trên dòng thời gian Trang chủ. Những người truy cập trang cá nhân của bạn, thông qua người theo dõi và thông qua ghi chú lại có thể thấy thông tin đó." +_timelineDescription: + home: "Trong dòng thời gian Trang chính, bạn có thể xem ghi chú từ các tài khoản bạn theo dõi." + local: "Trong dòng thời gian cục bộ, bạn có thể xem ghi chú từ tất cả người dùng trên máy chủ này." + social: "Dòng thời gian Xã hội hiển thị các ghi chú từ cả dòng thời gian Trang chủ và Địa phương." _serverSettings: iconUrl: "Biểu tượng URL" appIconResolutionMustBe: "Độ phân giải tối thiểu là {resolution}." @@ -1304,7 +1426,7 @@ _achievements: _postedAt0min0sec: title: "Tín hiệu báo giờ" description: "Đăng bài vào 0 phút 0 giây" - flavor: "Piiiiiii ĐÂY LÀ TIẾNG NÓI VIỆT NAM" + flavor: "Pin pop pop pop" _selfQuote: title: "Nói đến bản thân" description: "Trích dẫn bài viết của mình" @@ -1526,7 +1648,6 @@ _theme: header: "Ảnh bìa" navBg: "Nền thanh bên" navFg: "Chữ thanh bên" - navHoverFg: "Chữ thanh bên (Khi chạm)" navActive: "Chữ thanh bên (Khi chọn)" navIndicator: "Chỉ báo thanh bên" link: "Đường dẫn" @@ -1549,11 +1670,8 @@ _theme: buttonHoverBg: "Nền nút (Chạm)" inputBorder: "Đường viền khung soạn thảo" driveFolderBg: "Nền thư mục Ổ đĩa" - wallpaperOverlay: "Lớp phủ hình nền" badge: "Huy hiệu" messageBg: "Nền chat" - accentDarken: "Màu phụ (Tối)" - accentLighten: "Màu phụ (Sáng)" fgHighlighted: "Chữ nổi bật" _sfx: note: "Tút" @@ -1628,6 +1746,7 @@ _permissions: "write:gallery": "Sửa kho ảnh của tôi" "read:gallery-likes": "Xem danh sách các tút đã thích trong thư viện của tôi" "write:gallery-likes": "Sửa danh sách các tút đã thích trong thư viện của tôi" + "write:chat": "Soạn hoặc xóa tin nhắn" _auth: shareAccessTitle: "Cho phép truy cập app" shareAccess: "Bạn có muốn cho phép \"{name}\" truy cập vào tài khoản này không?" @@ -1922,11 +2041,25 @@ _abuseReport: _recipientType: mail: "Email" _moderationLogTypes: + createRole: "Tạo một vai trò" + deleteRole: "Xóa vai trò" + updateRole: "Cập nhật vai trò" + assignRole: "Chỉ định cho vai trò" + unassignRole: "Bỏ gán vai trò" suspend: "Vô hiệu hóa" + unsuspend: "Rã đông" resetPassword: "Đặt lại mật khẩu" createInvitation: "Tạo lời mời" _reversi: total: "Tổng cộng" +_customEmojisManager: + _local: + _list: + confirmDeleteEmojisDescription: "Xóa các biểu tượng cảm xúc {count} đã chọn. Bạn có muốn chạy nó không?" _remoteLookupErrors: _noSuchObject: title: "Không tìm thấy" +_search: + searchScopeAll: "Tất cả" + searchScopeLocal: "Máy chủ này" + searchScopeUser: "Người dùng chỉ định" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index f4df425af4..1508fca431 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "要删掉「{x}」吗?" resetAreYouSure: "恢复默认设置?" areYouSure: "你确定吗?" saved: "已保存" -messaging: "聊天" upload: "本地上传" keepOriginalUploading: "保留原图" keepOriginalUploadingDescription: "上传图片时保留原始图片。关闭时,浏览器会在上传时生成一张用于web发布的图片。" @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "上传可能需要一些时间完成。" explore: "发现" messageRead: "已读" noMoreHistory: "没有更多的历史记录" -startMessaging: "添加聊天" +startChat: "开始聊天" nUsersRead: "{n} 人已读" agreeTo: "勾选则表示已阅读并同意 {0}" agree: "同意" @@ -425,6 +424,7 @@ antennaExcludeBots: "排除机器人账户" antennaKeywordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" notifyAntenna: "开启通知" withFileAntenna: "仅带有附件的帖子" +excludeNotesInSensitiveChannel: "排除敏感频道内的帖子" enableServiceworker: "启用 ServiceWorker" antennaUsersDescription: "指定用户名,一行一个" caseSensitive: "区分大小写" @@ -491,8 +491,6 @@ noteOf: "{user} 的帖子" quoteAttached: "已引用" quoteQuestion: "是否引用此链接内容?" attachAsFileQuestion: "剪贴板内的文字过长。要转换为文本文件并添加吗?" -noMessagesYet: "现在没有新的聊天" -newMessageExists: "新信息" onlyOneFileCanBeAttached: "只能添加一个附件" signinRequired: "请先登录" signinOrContinueOnRemote: "若要继续,需要转到您所使用的实例,或者在此服务器上注册或登录。" @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name} 说了关于「{word}」的什么" makeActive: "启用" display: "显示" copy: "复制" +copiedToClipboard: "已复制到剪贴板" metrics: "指标" overview: "概览" logs: "日志" @@ -746,7 +745,7 @@ confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。 public: "公开" private: "私密" i18nInfo: "Misskey 已经被志愿者们翻译成了各种语言。如果你也有兴趣,可以通过 {link} 帮助翻译。" -manageAccessTokens: "管理 Access Tokens" +manageAccessTokens: "管理访问令牌" accountInfo: "账户信息" notesCount: "帖子数量" repliesCount: "回复数量" @@ -980,6 +979,7 @@ document: "文档" numberOfPageCache: "缓存页数" numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。" logoutConfirm: "是否确认登出?" +logoutWillClearClientData: "登出时将会从浏览器中删除客户端的设置信息。如果想要在再次登入时恢复设置信息,请在设置里打开自动备份。" lastActiveDate: "最后活跃时间" statusbar: "状态栏" pleaseSelect: "请选择" @@ -1309,6 +1309,135 @@ availableRoles: "可用角色" acknowledgeNotesAndEnable: "理解注意事项后再开启。" federationSpecified: "此服务器已开启联合白名单。只能与管理员指定的服务器通信。" federationDisabled: "此服务器已禁用联合。无法与其它服务器上的用户通信。" +confirmOnReact: "发送回应前需要确认" +reactAreYouSure: "要用「{emoji}」进行回应吗?" +markAsSensitiveConfirm: "要将此媒体标记为敏感吗?" +unmarkAsSensitiveConfirm: "要将此媒体解除敏感标记吗?" +preferences: "设置" +accessibility: "辅助功能" +preferencesProfile: "设置的配置" +copyPreferenceId: "复制设置 ID" +resetToDefaultValue: "重置为默认值" +overrideByAccount: "用账户覆盖" +untitled: "未命名" +noName: "没有名字" +skip: "跳过" +restore: "恢复" +syncBetweenDevices: "设备间同步" +preferenceSyncConflictTitle: "服务器上已存在设定值" +preferenceSyncConflictText: "服务器上已有此设置的设定值。要覆盖哪个设定值?" +preferenceSyncConflictChoiceServer: "服务器上的设定值" +preferenceSyncConflictChoiceDevice: "设备上的设定值" +preferenceSyncConflictChoiceCancel: "取消同步" +paste: "粘贴" +emojiPalette: "表情符号调色板" +postForm: "投稿窗口" +textCount: "字数" +information: "关于" +chat: "聊天" +migrateOldSettings: "迁移旧设置信息" +migrateOldSettings_description: "通常设置信息将自动迁移。但如果由于某种原因迁移不成功,则可以手动触发迁移过程。当前的配置信息将被覆盖。" +compress: "压缩" +right: "右" +bottom: "下" +top: "上" +embed: "嵌入" +settingsMigrating: "正在迁移设置,请稍候。(之后也可以在设置 → 其它 → 迁移旧设置来手动迁移)" +readonly: "只读" +goToDeck: "返回至 Deck" +federationJobs: "联合作业" +driveAboutTip: "网盘可以显示以前上传的文件。
\n也可以在发布帖子时重复使用文件,或在发布帖子前预先上传文件。
\n删除文件时,其将从至今为止所有用到该文件的地方(如帖子、页面、头像、横幅)消失。
\n也可以新建文件夹来整理文件。" +_chat: + noMessagesYet: "还没有消息" + newMessage: "新消息" + individualChat: "私聊" + individualChat_description: "可以与特定用户进行一对一聊天。" + roomChat: "群聊" + roomChat_description: "可以进行多人聊天。\n就算用户未允许私聊,只要接受了邀请,仍可以聊天。" + createRoom: "创建房间" + inviteUserToChat: "邀请用户来开始聊天" + yourRooms: "已创建的房间" + joiningRooms: "已加入的房间" + invitations: "邀请" + noInvitations: "没有邀请" + history: "历史" + noHistory: "没有历史记录" + noRooms: "没有房间" + inviteUser: "邀请用户" + sentInvitations: "已发送的邀请" + join: "加入" + ignore: "忽略" + leave: "退出房间" + members: "成员" + searchMessages: "搜索消息" + home: "首页" + send: "发送" + newline: "换行" + muteThisRoom: "静音此房间" + deleteRoom: "删除房间" + chatNotAvailableForThisAccountOrServer: "此服务器或者账户还未开启聊天功能。" + chatIsReadOnlyForThisAccountOrServer: "此服务器或者账户内的聊天为只读。无法发布新信息或创建及加入群聊。" + chatNotAvailableInOtherAccount: "对方账户目前处于无法使用聊天的状态。" + cannotChatWithTheUser: "无法与此用户聊天" + cannotChatWithTheUser_description: "可能现在无法使用聊天,或者对方未开启聊天。" + chatWithThisUser: "聊天" + thisUserAllowsChatOnlyFromFollowers: "此用户仅接受关注者发起的聊天。" + thisUserAllowsChatOnlyFromFollowing: "此用户仅接受关注的人发起的聊天。" + thisUserAllowsChatOnlyFromMutualFollowing: "此用户仅接受互相关注的人发起的聊天。" + thisUserNotAllowedChatAnyone: "此用户不接受任何人发起的聊天。" + chatAllowedUsers: "谁可以发起聊天" + chatAllowedUsers_note: "主动发起聊天时,对方将不受此设置限制。" + _chatAllowedUsers: + everyone: "任何人" + followers: "仅关注者" + following: "仅关注的人" + mutual: "仅相互关注" + none: "没有人" +_emojiPalette: + palettes: "调色板" + enableSyncBetweenDevicesForPalettes: "启用调色板的设备间同步" + paletteForMain: "主调色板" + paletteForReaction: "回应用调色板" +_settings: + driveBanner: "可在此管理和设置网盘、确认使用量及配置上传文件的设置。" + pluginBanner: "使用插件可以扩展客户端的功能。可以在此安装、单独管理插件。" + notificationsBanner: "可在此设置从服务器接收的通知的种类和范围,以及推送通知的设置。" + api: "API" + webhook: "Webhook" + serviceConnection: "连接服务" + serviceConnectionBanner: "可在此管理用于连接外部应用或服务的访问令牌及 Webhook。" + accountData: "账户数据" + accountDataBanner: "可在此导入或导出帐户数据的存档。" + muteAndBlockBanner: "可在此设置隐藏内容,或限制指定用户能进行的操作。" + accessibilityBanner: "可在此设置客户端的显示及动态效果等辅助设置。" + privacyBanner: "可在此设置如内容可见性、可发现性、批准关注请求等账户隐私设置。" + securityBanner: "可在此设置如密码、登入方式、验证器、Passkey 等账户安全性设置。" + preferencesBanner: "可在此设置客户端的整体运作行为。" + appearanceBanner: "可在此设置客户端的外观及显示方式。" + soundsBanner: "可在此设置客户端播放的声音。" + timelineAndNote: "时间线和帖子" + makeEveryTextElementsSelectable: "使所有的文字均可选择" + makeEveryTextElementsSelectable_description: "若开启,在某些情况下可能降低用户体验。" + useStickyIcons: "使图标跟随滚动" + showNavbarSubButtons: "在导航栏中显示副按钮" + ifOn: "启用时" + ifOff: "关闭时" + _chat: + showSenderName: "显示发送者的名字" + sendOnEnter: "回车键发送" +_preferencesProfile: + profileName: "配置名" + profileNameDescription: "请指定用于识别此设备的名称" + profileNameDescription2: "如「PC」、「手机」等" +_preferencesBackup: + autoBackup: "自动备份" + restoreFromBackup: "从备份恢复" + noBackupsFoundTitle: "没有找到备份" + noBackupsFoundDescription: "没有找到自动备份。若有手动保存备份文件,可将其导入来恢复。" + selectBackupToRestore: "请选择要恢复的备份" + youNeedToNameYourProfileToEnableAutoBackup: "需指定配置名以开启自动备份。" + autoPreferencesBackupIsNotEnabledForThisDevice: "此设备未开启自动备份" + backupFound: "已找到备份" _accountSettings: requireSigninToViewContents: "需要登录才能显示内容" requireSigninToViewContentsDescription1: "您发布的所有帖子将变成需要登入后才会显示。有望防止爬虫收集各种信息。" @@ -1319,6 +1448,7 @@ _accountSettings: makeNotesHiddenBefore: "将过去的帖子设为私密" makeNotesHiddenBeforeDescription: "开启此设定时,超过设定的时间或日期后,帖子将变为仅自己可见。关闭后帖子的公开状态将恢复成原本的设定。" mayNotEffectForFederatedNotes: "与远程服务器联合的帖子在远端可能会没有效果。" + mayNotEffectSomeSituations: "此限制功能非常简单,在与远程服务器联合等情形时可能不适用。" notesHavePassedSpecifiedPeriod: "超过指定时间的帖子" notesOlderThanSpecifiedDateAndTime: "指定日期前的帖子" _abuseUserReport: @@ -1764,6 +1894,8 @@ _role: descriptionOfIsExplorable: "打开后将公开角色时间线。如果角色不是公开的,就无法公开时间线。" displayOrder: "显示顺序" descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" + preserveAssignmentOnMoveAccount: "将分配状态继承到目标账户" + preserveAssignmentOnMoveAccount_description: "启用后,当迁移具有该角色的账户时,目标账户也会继承该角色。" canEditMembersByModerator: "允许监察员编辑成员" descriptionOfCanEditMembersByModerator: "如果选中,监察员和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" @@ -1783,6 +1915,7 @@ _role: canManageCustomEmojis: "管理自定义表情符号" canManageAvatarDecorations: "管理头像挂件" driveCapacity: "网盘容量" + maxFileSize: "可上传的最大文件大小" alwaysMarkNsfw: "总是将文件标记为 NSFW" canUpdateBioMedia: "可以更新头像和横幅" pinMax: "帖子置顶数量限制" @@ -1804,6 +1937,7 @@ _role: canImportFollowing: "允许导入关注列表" canImportMuting: "允许导入隐藏列表" canImportUserLists: "允许导入用户列表" + chatAvailability: "允许聊天" _condition: roleAssignedTo: "已分配给手动角色" isLocal: "是本地用户" @@ -1967,6 +2101,7 @@ _theme: installed: "{name} 已安装" installedThemes: "已安装的主题" builtinThemes: "标准主题" + instanceTheme: "服务器主题" alreadyInstalled: "此主题已经安装" invalid: "主题格式错误" make: "制作主题" @@ -1999,7 +2134,6 @@ _theme: header: "顶栏" navBg: "侧边栏背景" navFg: "侧栏文本" - navHoverFg: "侧栏文本(悬停)" navActive: "侧栏文本(活动)" navIndicator: "侧栏标记" link: "链接" @@ -2022,17 +2156,15 @@ _theme: buttonHoverBg: "按钮背景(悬停)" inputBorder: "输入框边框" driveFolderBg: "网盘的文件夹背景" - wallpaperOverlay: "壁纸叠加层" badge: "徽章" messageBg: "聊天背景" - accentDarken: "强调色(深)" - accentLighten: "强调色(浅)" fgHighlighted: "高亮显示文本" _sfx: note: "帖子" noteMy: "我的帖子" notification: "通知" reaction: "选择回应时" + chatMessage: "聊天信息" _soundSettings: driveFile: "使用网盘内的音频" driveFileWarn: "选择网盘上的文件" @@ -2179,6 +2311,8 @@ _permissions: "read:clip-favorite": "查看便签的点赞" "read:federation": "查看联合相关信息" "write:report-abuse": "举报用户" + "write:chat": "撰写或删除消息" + "read:chat": "查看聊天" _auth: shareAccessTitle: "应用程序授权许可" shareAccess: "您要授权允许 “{name}” 访问您的帐户吗?" @@ -2237,6 +2371,7 @@ _widgets: chooseList: "选择列表" clicker: "点击器" birthdayFollowings: "今天是他们的生日" + chat: "聊天" _cw: hide: "隐藏" show: "查看更多" @@ -2427,6 +2562,7 @@ _notification: newNote: "新的帖子" unreadAntennaNote: "天线 {name}" roleAssigned: "授予的角色" + chatRoomInvitationReceived: "受邀加入聊天室" emptyPushNotificationMessage: "推送通知已更新" achievementEarned: "获得成就" testNotification: "测试通知" @@ -2440,6 +2576,8 @@ _notification: flushNotification: "重置通知历史" exportOfXCompleted: "已完成 {x} 的导出" login: "有新的登录" + createToken: "访问令牌已创建" + createTokenDescription: "如果不明白其用途,请遵循「{text}」的指示删除访问令牌。" _types: all: "全部" note: "用户的新帖子" @@ -2453,9 +2591,11 @@ _notification: receiveFollowRequest: "收到关注请求" followRequestAccepted: "关注请求已通过" roleAssigned: "授予的角色" + chatRoomInvitationReceived: "受邀加入聊天室" achievementEarned: "取得的成就" exportCompleted: "已完成导出" login: "登录" + createToken: "创建访问令牌" test: "测试通知" app: "关联应用的通知" _actions: @@ -2465,6 +2605,9 @@ _notification: _deck: alwaysShowMainColumn: "总是显示主列" columnAlign: "列对齐" + columnGap: "列间距" + deckMenuPosition: "Deck 菜单位置" + navbarPosition: "导航栏位置" addColumn: "添加列" newNoteNotificationSettings: "新帖子通知设定" configureColumn: "列设置" @@ -2478,11 +2621,12 @@ _deck: newProfile: "新建配置文件" deleteProfile: "删除配置文件" introduction: "将各列进行组合以创建您自己的界面!" - introduction2: "您可以随时通过屏幕右侧的 + 来添加列" + introduction2: "可以随时通过屏幕右侧的 + 来添加列" widgetsIntroduction: "从列菜单中,选择“小工具编辑”来添加小工具" useSimpleUiForNonRootPages: "用简易UI表示非根页面" usedAsMinWidthWhenFlexible: "「自适应宽度」被启用的时候,这就是最小的宽度" flexible: "自适应宽度" + enableSyncBetweenDevicesForProfiles: "启用个人资料信息跨设备同步" _columns: main: "主列" widgets: "小工具" @@ -2494,6 +2638,7 @@ _deck: mentions: "提及" direct: "指定用户" roleTimeline: "角色时间线" + chat: "聊天" _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" @@ -2590,6 +2735,8 @@ _moderationLogTypes: deletePage: "删除了页面" deleteFlash: "删除了 Play" deleteGalleryPost: "删除了图库稿件" + deleteChatRoom: "删除聊天室" + updateProxyAccountDescription: "更新代理账户的简介" _fileViewer: title: "文件信息" type: "文件类型" @@ -2603,10 +2750,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "请在安装前确保来源可靠" _plugin: title: "要安装此插件吗?" - metaTitle: "插件信息" _theme: title: "要安装此主题吗?" - metaTitle: "主题信息" _meta: base: "基本配色方案" _vendorInfo: @@ -2777,8 +2922,8 @@ _customEmojisManager: _register: uploadSettingTitle: "上传设置" uploadSettingDescription: "可以在此页面设置上传表情符号时的行为。" - directoryToCategoryLabel: "目录名请输入「category」" - directoryToCategoryCaption: "拖放目录时,目录名请输入「category」" + directoryToCategoryLabel: "将目录名设为「category」" + directoryToCategoryCaption: "拖放目录时,将目录名设置为「category」" emojiInputAreaCaption: "请使用其中一种方法选择要注册的表情符号。" emojiInputAreaList1: "在此区域内拖放图像文件或者目录" emojiInputAreaList2: "单击此链接以从电脑中选择" @@ -2822,8 +2967,6 @@ _remoteLookupErrors: _responseInvalid: title: "响应无效" description: "成功与此服务器通信,但返回的数据无效。" - _responseInvalidIdHostNotMatch: - description: "输入 URI 的域名和最终取得的 URI 的域名不同。如果是通过第三方服务器获取远程内容,请使用可以从原始服务器获取内容的 URI 再试一次。" _noSuchObject: title: "未找到" description: "未找到请求的资源。请再次检查 URI。" @@ -2840,3 +2983,23 @@ _captcha: _unknown: title: "CAPTCHA 错误" text: "发生意外错误。" +_bootErrors: + title: "加载失败" + serverError: "请稍等片刻再重试。若问题仍无法解决,请将以下 Error ID 一起发送给管理员。" + solution: "以下方法或许可以解决问题:" + solution1: "将浏览器及操作系统更新到最新版本" + solution2: "禁用广告屏蔽插件" + solution3: "清除浏览器缓存" + solution4: "(Tor Browser)将 dom.webaudio.enabled 设定为 true" + otherOption: "其它选项" + otherOption1: "清除客户端设定与缓存" + otherOption2: "使用简易客户端" + otherOption3: "启动修复工具" +_search: + searchScopeAll: "全部" + searchScopeLocal: "本地" + searchScopeServer: "指定服务器" + searchScopeUser: "指定用户" + pleaseEnterServerHost: "请填写服务器主机名" + pleaseSelectUser: "请选择用户" + serverHostPlaceholder: "如:misskey.example.com" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 466e3cc1d8..152f34feeb 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -103,7 +103,7 @@ serverIsDead: "伺服器沒有回應。請稍等片刻再試。" youShouldUpgradeClient: "請重新載入以使用新版客戶端顯示此頁面。" enterListName: "輸入清單名稱" privacy: "隱私" -makeFollowManuallyApprove: "手動審核追隨請求" +makeFollowManuallyApprove: "追隨需要核准" defaultNoteVisibility: "預設可見性" follow: "追隨" followRequest: "追隨請求" @@ -289,7 +289,6 @@ deleteAreYouSure: "確定要刪掉「{x}」嗎?" resetAreYouSure: "確定要重設嗎?" areYouSure: "是否確定?" saved: "已儲存" -messaging: "聊天" upload: "上傳" keepOriginalUploading: "保留原圖" keepOriginalUploadingDescription: "上傳圖片時保留原始圖片。關閉時,瀏覽器會在上傳時生成適用於網路傳送的版本。" @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "還需要一些時間才能完成上傳。" explore: "探索" messageRead: "已讀" noMoreHistory: "沒有更多歷史紀錄" -startMessaging: "開始聊天" +startChat: "開始聊天" nUsersRead: "{n} 人已讀" agreeTo: "我同意{0}" agree: "同意" @@ -368,7 +367,7 @@ normal: "正常" instanceName: "伺服器名稱" instanceDescription: "伺服器介紹" maintainerName: "管理員名稱" -maintainerEmail: "管理員郵箱" +maintainerEmail: "管理員信箱" tosUrl: "服務條款 URL" thisYear: "本年" thisMonth: "本月" @@ -425,6 +424,7 @@ antennaExcludeBots: "排除機器人帳戶" antennaKeywordsDescription: "空格代表「以及」(AND),換行代表「或者」(OR)" notifyAntenna: "通知有新貼文" withFileAntenna: "僅帶有附件的貼文" +excludeNotesInSensitiveChannel: "排除敏感頻道的貼文" enableServiceworker: "啟用瀏覽器的推播通知" antennaUsersDescription: "填寫使用者名稱,以換行分隔" caseSensitive: "區分大小寫" @@ -459,13 +459,13 @@ moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解 addModerationNote: "新增管理筆記" moderationLogs: "管理日誌" nUsersMentioned: "被 {n} 個人提及" -securityKeyAndPasskey: "安全金鑰、Passkey" +securityKeyAndPasskey: "安全金鑰、通行金鑰" securityKey: "安全金鑰" lastUsed: "上次使用" lastUsedAt: "上次使用:{t}" unregister: "註銷" -passwordLessLogin: "設置無密碼登入" -passwordLessLoginDescription: "不使用密碼,以安全金鑰或 Passkey 登入" +passwordLessLogin: "無密碼登入" +passwordLessLoginDescription: "不使用密碼,以安全金鑰或通行金鑰登入" resetPassword: "重設密碼" newPasswordIs: "新密碼為「{password}」" reduceUiAnimation: "減少介面的動態視覺" @@ -491,8 +491,6 @@ noteOf: "{user}的貼文" quoteAttached: "引用" quoteQuestion: "是否要引用?" attachAsFileQuestion: "剪貼簿的文字較長。請問是否要將其以文字檔的方式附加呢?" -noMessagesYet: "沒有訊息" -newMessageExists: "有新的訊息" onlyOneFileCanBeAttached: "只能加入一個附件" signinRequired: "請先登入" signinOrContinueOnRemote: "若要繼續,需前往您所在的伺服器,或者註冊並登入此伺服器" @@ -521,7 +519,7 @@ menuStyle: "選單風格" style: "風格" drawer: "側邊欄" popup: "彈出式視窗" -showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的" +showNoteActionsOnlyHover: "僅於游標懸停時顯示貼文選項" showReactionsCount: "顯示貼文的反應數目" noHistory: "沒有歷史紀錄" signinHistory: "登入歷史" @@ -558,12 +556,12 @@ useObjectStorage: "使用物件儲存" objectStorageBaseUrl: "Base URL" objectStorageBaseUrlDesc: "用於引用的 URL。如果您使用的是 CDN 或反向代理,請指定其 URL,例如 S3(https://.s3.amazonaws.com)、GCS(https://storage.googleapis.com/)。" objectStorageBucket: "儲存空間(Bucket)" -objectStorageBucketDesc: "請填寫所用服務的儲存空間(Bucket)名稱。 " +objectStorageBucketDesc: "請填寫所用服務的儲存桶(Bucket)名稱。 " objectStoragePrefix: "前綴" objectStoragePrefixDesc: "它儲存在此前綴目錄下。" objectStorageEndpoint: "端點(Endpoint)" objectStorageEndpointDesc: "如使用 AWS S3,請留空。如使用其他服務,請按照其說明文件以「」或「:」的形式設定端點(Endpoint)。" -objectStorageRegion: "地域(Region)" +objectStorageRegion: "區域(Region)" objectStorageRegionDesc: "請填寫一個分區,例如「xx-east-1」。 如果您使用的服務不設分區,請留空或填寫「us-east-1」。" objectStorageUseSSL: "使用 SSL" objectStorageUseSSLDesc: "請在不使用 https 連接 API 時關閉" @@ -586,7 +584,7 @@ popout: "彈出式視窗" volume: "音量" masterVolume: "主音量" notUseSound: "關閉音效" -useSoundOnlyWhenActive: "瀏覽器在前景運作時,Misskey 才會發出音效" +useSoundOnlyWhenActive: "僅在 Misskey 於前景運作時發出音效" details: "詳細資訊" renoteDetails: "轉發貼文的細節" chooseEmoji: "選擇您的表情符號" @@ -681,7 +679,7 @@ smtpHost: "主機" smtpPort: "埠" smtpUser: "使用者名稱" smtpPass: "密碼" -emptyToDisableSmtpAuth: "留空使用者名稱和密碼以關閉SMTP驗證。" +emptyToDisableSmtpAuth: "將使用者名稱和密碼留空以關閉 SMTP 驗證。" smtpSecure: "在 SMTP 連接中使用隱式 SSL/TLS" smtpSecureInfo: "使用 STARTTLS 時關閉。" testEmail: "測試郵件發送" @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name} 說了一些關於「{word}」的話" makeActive: "啟用" display: "檢視" copy: "複製" +copiedToClipboard: "已複製到剪貼簿" metrics: "指標" overview: "概覽" logs: "日誌" @@ -711,7 +710,7 @@ useGlobalSetting: "使用全域設定" useGlobalSettingDesc: "啟用時,將使用帳戶通知設定。停用時,則可以單獨設定。" other: "其他" regenerateLoginToken: "重新產生登入權杖" -regenerateLoginTokenDescription: "重新產生用於登入的內部權杖。一般情況下是不需要這樣做的。重新產生後,所有裝置將會被登出。" +regenerateLoginTokenDescription: "重新產生用於登入的內部權杖。通常不需要使用此功能。重新產生後,所有裝置都將被登出。" theKeywordWhenSearchingForCustomEmoji: "這是搜尋自訂表情符號時的關鍵字" setMultipleBySeparatingWithSpace: "您可以使用空格分隔多個項目。" fileIdOrUrl: "檔案 ID 或 URL" @@ -745,7 +744,7 @@ unclip: "解除摘錄" confirmToUnclipAlreadyClippedNote: "此貼文已包含在摘錄「{name}」中。 你想將貼文從這個摘錄中排除嗎?" public: "公開" private: "私密" -i18nInfo: "Misskey 已被志願者們翻譯成各種語言版本。您可以瀏覽 {link} 幫助翻譯。" +i18nInfo: "Misskey 已被志願者們翻譯成各種語言版本。您可以前往 {link} 以協助翻譯。" manageAccessTokens: "管理存取權杖" accountInfo: "帳戶資訊" notesCount: "貼文數量" @@ -765,7 +764,7 @@ driveFilesCount: "雲端硬碟檔案數量" driveUsage: "雲端硬碟使用量" noCrawle: "拒絕搜尋引擎索引" noCrawleDescription: "要求網路搜尋引擎不要索引你的個人資料頁、貼文及頁面等。" -lockedAccountInfo: "即使你通過了追隨者請求,除非你將貼文的可見性設定為 「追隨者」,否則任何人都能看見你的貼文。" +lockedAccountInfo: "即使追隨需要核准,除非你將貼文的可見性設定為 「追隨者」,否則任何人都能看見你的貼文。" alwaysMarkSensitive: "預設標記檔案為敏感內容" loadRawImages: "以原始圖檔顯示附件圖檔的縮圖" disableShowingAnimatedImages: "不播放動態圖檔" @@ -781,7 +780,7 @@ useSystemFont: "使用系統預設的字型" clips: "摘錄" experimentalFeatures: "實驗中的功能" experimental: "實驗性" -thisIsExperimentalFeature: "這是實驗性的功能。可能會有變更規格和不能正常動作的可能性。" +thisIsExperimentalFeature: "這是一項實驗性功能,其行為會隨需要進行調整,也可能無法正常運作。" developer: "開發者" makeExplorable: "使自己的帳戶更容易被找到" makeExplorableDescription: "如果關閉,帳戶將不會被顯示在「探索」頁面中。" @@ -980,6 +979,7 @@ document: "文件" numberOfPageCache: "快取頁面數" numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" logoutConfirm: "確定要登出嗎?" +logoutWillClearClientData: "當您登出時,客戶端的設定資訊將從瀏覽器中清除。為了能夠在重新登入時恢復您的設定資訊,請啟用設定內的自動備份選項。" lastActiveDate: "上次使用日期及時間" statusbar: "狀態列" pleaseSelect: "請選擇" @@ -1188,7 +1188,7 @@ forYou: "給您" currentAnnouncements: "最新公告" pastAnnouncements: "歷史公告" youHaveUnreadAnnouncements: "有未讀的公告。" -useSecurityKey: "請按照瀏覽器或裝置上的說明來使用安全金鑰或 Passkey。" +useSecurityKey: "請按照瀏覽器或裝置上的說明來使用安全金鑰或通行金鑰。" replies: "回覆" renotes: "轉發" loadReplies: "閱覽回覆" @@ -1205,9 +1205,9 @@ showRenotes: "顯示其他人的轉發貼文" edited: "已編輯" notificationRecieveConfig: "接受通知的設定" mutualFollow: "互相追隨" -followingOrFollower: "追隨中或者追隨者" +followingOrFollower: "追隨中或追隨者" fileAttachedOnly: "只顯示包含附件的貼文" -showRepliesToOthersInTimeline: "顯示給其他人的回覆" +showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" showRepliesToOthersInTimelineAll: "在時間軸包含追隨中所有人的回覆" hideRepliesToOthersInTimelineAll: "在時間軸不包含追隨中所有人的回覆" @@ -1247,7 +1247,7 @@ reloadRequiredToApplySettings: "需要重新載入頁面設定才能生效。" remainingN: "剩餘:{n}" overwriteContentConfirm: "確定要覆蓋目前的內容嗎?" seasonalScreenEffect: "隨季節變換畫面的呈現" -decorate: "設置頭像裝飾" +decorate: "裝飾" addMfmFunction: "插入 MFM 功能語法" enableQuickAddMfmFunction: "顯示進階 MFM 選擇器" bubbleGame: "氣泡遊戲" @@ -1274,7 +1274,7 @@ useBackupCode: "使用備用驗證碼" launchApp: "啟動 APP" useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊" keepOriginalFilename: "保留原始檔名" -keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。" +keepOriginalFilenameDescription: "如果關閉此設定,上傳時檔案名稱會自動替換為隨機字串。" noDescription: "沒有說明文字" alwaysConfirmFollow: "追隨時總是確認" inquiry: "聯絡我們" @@ -1291,10 +1291,10 @@ performance: "性能" modified: "已變更" discard: "取消" thereAreNChanges: "有 {n} 處的變更" -signinWithPasskey: "使用密碼金鑰登入" -unknownWebAuthnKey: "未註冊的金鑰。" -passkeyVerificationFailed: "驗證金鑰失敗。" -passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。" +signinWithPasskey: "使用通行金鑰登入" +unknownWebAuthnKey: "未註冊的通行金鑰。" +passkeyVerificationFailed: "驗證通行金鑰失敗。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證通行金鑰成功,但是無密碼登入的方式是停用的。" messageToFollower: "給追隨者的訊息" target: "目標 " testCaptchaWarning: "此功能用於 CAPTCHA 的測試。請勿在正式環境中使用。" @@ -1309,16 +1309,147 @@ availableRoles: "可用角色" acknowledgeNotesAndEnable: "了解注意事項後再開啟。" federationSpecified: "此伺服器以白名單聯邦的方式運作。除了管理員指定的伺服器外,它無法與其他伺服器互動。" federationDisabled: "此伺服器未開啟站台聯邦。無法與其他伺服器上的使用者互動。" +confirmOnReact: "在做出反應前先確認" +reactAreYouSure: "用「 {emoji} 」反應嗎?" +markAsSensitiveConfirm: "要將這個媒體設定為敏感嗎?" +unmarkAsSensitiveConfirm: "要解除這個媒體的敏感設定嗎?" +preferences: "環境設定" +accessibility: "輔助工具" +preferencesProfile: "設定檔案" +copyPreferenceId: "複製設定 ID" +resetToDefaultValue: "還原成預設值" +overrideByAccount: "覆寫帳號" +untitled: "無標題" +noName: "沒有名稱" +skip: "跳過" +restore: "還原" +syncBetweenDevices: "裝置之間的同步化" +preferenceSyncConflictTitle: "伺服器上存在設定值" +preferenceSyncConflictText: "已啟用同步的設定項目會將設定值儲存至伺服器,並已找到該設定項目在伺服器上儲存的設定值。請選擇要使用哪個設定值進行覆寫。" +preferenceSyncConflictChoiceServer: "伺服器設定值" +preferenceSyncConflictChoiceDevice: "裝置的設定值" +preferenceSyncConflictChoiceCancel: "取消啟用同步" +paste: "貼上" +emojiPalette: "表情符號調色盤" +postForm: "發文視窗" +textCount: "字數" +information: "關於" +chat: "聊天" +migrateOldSettings: "遷移舊設定資訊" +migrateOldSettings_description: "通常情況下,這會自動進行,但若因某些原因未能順利遷移,您可以手動觸發遷移處理。請注意,當前的設定資訊將會被覆寫。" +compress: "壓縮" +right: "右" +bottom: "下" +top: "上" +embed: "嵌入" +settingsMigrating: "正在移轉設定。請稍候……(之後也可以到「設定 → 其他 → 舊設定資訊移轉」中手動進行移轉)" +readonly: "唯讀" +goToDeck: "回去甲板" +federationJobs: "聯邦通訊作業" +driveAboutTip: "在「雲端硬碟」中,會顯示過去上傳的檔案列表。
\n可以在附加到貼文時重新利用,或者事先上傳之後再用於發布。
\n請注意,刪除檔案後,之前使用過該檔案的所有地方(貼文、頁面、大頭貼、橫幅等)也會一併無法顯示。
\n也可以建立資料夾來整理檔案。" +_chat: + noMessagesYet: "尚無訊息" + newMessage: "新訊息" + individualChat: "ㄧ對一聊天室" + individualChat_description: "可以與特定使用者進行一對一的聊天。" + roomChat: "多人聊天室" + roomChat_description: "可以進行多人聊天。\n此外,即使是未允許個人聊天的使用者,只要對方接受,也可以進行聊天。" + createRoom: "建立聊天室" + inviteUserToChat: "邀請使用者開始聊天" + yourRooms: "已建立的聊天室" + joiningRooms: "已加入的聊天室" + invitations: "邀請" + noInvitations: "沒有邀請" + history: "歷史紀錄" + noHistory: "沒有歷史紀錄" + noRooms: "沒有可用的聊天室" + inviteUser: "邀請使用者" + sentInvitations: "已傳送的邀請" + join: "加入" + ignore: "忽視" + leave: "退出聊天室" + members: "成員" + searchMessages: "搜尋聊天訊息" + home: "首頁" + send: "發送" + newline: "換行" + muteThisRoom: "此聊天室已靜音" + deleteRoom: "刪除聊天室" + chatNotAvailableForThisAccountOrServer: "這個伺服器或這個帳號的聊天功能尚未啟用。" + chatIsReadOnlyForThisAccountOrServer: "在此伺服器或此帳戶上的聊天是唯讀的。您無法發布新訊息、建立或加入聊天室。" + chatNotAvailableInOtherAccount: "對方的帳號無法使用聊天功能。" + cannotChatWithTheUser: "無法與此使用者聊天" + cannotChatWithTheUser_description: "聊天功能目前無法使用,或對方尚未開放聊天功能。" + chatWithThisUser: "聊天" + thisUserAllowsChatOnlyFromFollowers: "此使用者僅接受來自追隨者的聊天訊息。" + thisUserAllowsChatOnlyFromFollowing: "此使用者僅接受自己追隨的使用者傳送聊天訊息。" + thisUserAllowsChatOnlyFromMutualFollowing: "此使用者只接受互相追隨的使用者傳送聊天訊息。" + thisUserNotAllowedChatAnyone: "此使用者不接受來自任何人的聊天訊息。" + chatAllowedUsers: "允許聊天的對象" + chatAllowedUsers_note: "無論此設定為何,您仍可與自己曾發送過聊天訊息的對象進行聊天。" + _chatAllowedUsers: + everyone: "任何人" + followers: "追隨自己的使用者" + following: "只有您追隨的使用者" + mutual: "互相追隨" + none: "無" +_emojiPalette: + palettes: "調色盤" + enableSyncBetweenDevicesForPalettes: "啟用裝置與裝置之間的調色盤同步化" + paletteForMain: "主要使用的調色盤" + paletteForReaction: "反應用的調色盤" +_settings: + driveBanner: "您可以管理和設定雲端硬碟、確認使用量,以及調整上傳檔案時的設定。" + pluginBanner: "可使用外掛擴充用戶端的功能。您可以安裝外掛,實施個別的設定與管理。" + notificationsBanner: "您可以設定從伺服器接收通知的類型和範圍,以及推送通知。" + api: "API" + webhook: "Webhook" + serviceConnection: "服務整合" + serviceConnectionBanner: "您可以管理和設定存取權杖與 Webhooks,以便與外部應用程式和服務整合。" + accountData: "帳戶資料" + accountDataBanner: "您可以管理帳戶資料的匯出 / 匯入。" + muteAndBlockBanner: "您可以設定和管理要隱藏的內容,並限制特定使用者的行動。" + accessibilityBanner: "可針對客戶端的視覺和行為進行個人化設定,以達到更佳的使用效果。" + privacyBanner: "您可以調整帳戶的隱私設定,例如內容的可見性、尋找內容的容易程度,以及追隨是否需要核准。" + securityBanner: "您可以設定與帳戶安全性相關的設定,例如密碼、登入方式、驗證應用程式和通行金鑰。" + preferencesBanner: "您可以根據喜好設定用戶端的整體行為。" + appearanceBanner: "您可以根據喜好設定與用戶端外觀和顯示方式相關的設定。" + soundsBanner: "您可以調整用戶端播放的聲音設定。" + timelineAndNote: "時間軸及貼文" + makeEveryTextElementsSelectable: "允許選取所有文字" + makeEveryTextElementsSelectable_description: "啟用此功能後,可能會在某些情境下降低可用性。" + useStickyIcons: "使大頭貼跟隨捲動" + showNavbarSubButtons: "在導覽列顯示輔助按鈕" + ifOn: "開啟時" + ifOff: "關閉時" + enableSyncThemesBetweenDevices: "在裝置之間同步已安裝的主題" + _chat: + showSenderName: "顯示發送者的名稱" + sendOnEnter: "按下 Enter 發送訊息" +_preferencesProfile: + profileName: "設定檔案名稱" + profileNameDescription: "設定一個名稱來識別此裝置。" + profileNameDescription2: "例如:「主要個人電腦」、「智慧型手機」等" +_preferencesBackup: + autoBackup: "自動備份" + restoreFromBackup: "從備份還原" + noBackupsFoundTitle: "找不到備份檔" + noBackupsFoundDescription: "沒有找到自動建立的備份,但如果您手動儲存了備份檔案,則可以匯入並還原。" + selectBackupToRestore: "選擇要還原的備份" + youNeedToNameYourProfileToEnableAutoBackup: "要啟用自動備份,必須設定檔案名稱。" + autoPreferencesBackupIsNotEnabledForThisDevice: "此裝置未啟用自動備份設定。" + backupFound: "找到設定的備份" _accountSettings: requireSigninToViewContents: "須登入以顯示內容" requireSigninToViewContentsDescription1: "必須登入才會顯示您建立的貼文等內容。可望有效防止資訊被爬蟲蒐集。" - requireSigninToViewContentsDescription2: "來自不支援 URL 預覽 (OGP)、 網頁嵌入和引用貼文的伺服器,也將停止顯示。" + requireSigninToViewContentsDescription2: "針對您貼文的 URL 預覽 (OGP) 與網頁嵌入功能將會無法使用。而不支援引用貼文的伺服器,也將停止顯示。" requireSigninToViewContentsDescription3: "這些限制可能不適用於被聯邦發送至遠端伺服器的內容。" makeNotesFollowersOnlyBefore: "讓過去的貼文僅對追隨者顯示" makeNotesFollowersOnlyBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對追隨者顯示。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" makeNotesHiddenBefore: "隱藏過去的貼文" makeNotesHiddenBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對自己顯示(私密化)。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" mayNotEffectForFederatedNotes: "聯邦發送至遠端伺服器的貼文可能會不受影響。" + mayNotEffectSomeSituations: "這些限制僅是簡化版本。在某些情況下,例如在遠端伺服器上瀏覽或進行審核時,可能不會套用這些限制。" notesHavePassedSpecifiedPeriod: "早於指定時間的貼文" notesOlderThanSpecifiedDateAndTime: "指定時間和日期之前的貼文" _abuseUserReport: @@ -1433,7 +1564,7 @@ _initialTutorial: useCases: "伺服器的服務條款可能會規範特定的貼文需要使用隱藏內容,除此之外也會用在隱藏劇情洩漏與敏感內容的貼文。" _howToMakeAttachmentsSensitive: title: "如何標記上傳附件為敏感內容?" - description: "如果伺服器服務條款有規範,又或者不希望上傳附件直接被看見,可以設置為「敏感內容」" + description: "如果伺服器的服務條款有規範,又或者不適合直接展示的附件,請記得加上「敏感」標記。" tryThisFile: "試試看!把附加在發文表單的圖像檔案標記為敏感內容。" _exampleNote: note: "打開納豆的包裝失敗了…" @@ -1467,7 +1598,7 @@ _serverSettings: inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。" openRegistration: "允許建立帳戶" openRegistrationWarning: "開放註冊伴隨著風險。 建議只有在伺服器受到持續監控,並準備好在出現問題時能立即處理的情況下才開放註冊。" - thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam,如果一段期間內沒有偵測到審查員的活動,此設定將自動關閉。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "如果在一段期間內沒有偵測到任何審查員活動,此設定將自動關閉,以防止垃圾內容。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" @@ -1481,7 +1612,7 @@ _accountMigration: startMigration: "遷移" migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。" - postMigrationNote: "將在完成遷移後的 24 小時取消追隨所有帳號。\n此帳戶的追隨中/追隨者人數將歸零。由於不會解除粉絲對您的追隨,因此他們仍然可以繼續閱覽此帳戶僅對追隨者公開的貼文。" + postMigrationNote: "將在完成遷移的 24 小時後取消追隨所有帳號。\n此帳戶的追隨中/追隨者人數將歸零。由於不會解除粉絲對您的追隨,因此他們仍然可以繼續閱覽此帳戶內僅對追隨者公開的貼文。" movedTo: "要遷移到的帳戶:" _achievements: earnedAt: "獲得日期" @@ -1764,6 +1895,8 @@ _role: descriptionOfIsExplorable: "若開啟則公開角色時間軸。若角色不是公開的,則無法公開時間軸。" displayOrder: "顯示順序" descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" + preserveAssignmentOnMoveAccount: "將指派狀態承接至轉移後的帳戶" + preserveAssignmentOnMoveAccount_description: "開啟此選項後,當具備此角色的帳戶被移轉時,該角色也會承接至轉移後的帳戶。" canEditMembersByModerator: "允許編輯審查員的成員" descriptionOfCanEditMembersByModerator: "如果開啟,管理員與審查員都可以為使用者指派/解除指派該角色。如果關閉,則只有管理員可以執行。" priority: "優先級" @@ -1783,6 +1916,7 @@ _role: canManageCustomEmojis: "管理自訂表情符號" canManageAvatarDecorations: "管理頭像裝飾" driveCapacity: "雲端硬碟容量" + maxFileSize: "可上傳的最大檔案大小" alwaysMarkNsfw: "總是將檔案標記為NSFW" canUpdateBioMedia: "允許更新大頭貼和橫幅" pinMax: "置頂貼文的最大數量" @@ -1798,12 +1932,13 @@ _role: canHideAds: "不顯示廣告" canSearchNotes: "可否搜尋貼文" canUseTranslator: "使用翻譯功能" - avatarDecorationLimit: "頭像裝飾的最大設置量" + avatarDecorationLimit: "頭像可掛上的最大裝飾數量" canImportAntennas: "允許匯入天線" canImportBlocking: "允許匯入封鎖名單" canImportFollowing: "允許匯入追隨名單" canImportMuting: "允許匯入靜音名單" canImportUserLists: "允許匯入清單" + chatAvailability: "允許聊天" _condition: roleAssignedTo: "手動指派角色完成" isLocal: "本地使用者" @@ -1957,7 +2092,7 @@ _instanceMute: instanceMuteDescription: "包括對被靜音伺服器上的使用者的回覆,被設定的伺服器上所有貼文及轉發都會被靜音。" instanceMuteDescription2: "設定時以換行進行分隔" title: "將隱藏被設定的伺服器貼文。" - heading: "將伺服器靜音" + heading: "要靜音的伺服器" _theme: explore: "探索佈景主題" install: "安裝佈景主題" @@ -1967,6 +2102,7 @@ _theme: installed: "{name}已安裝" installedThemes: "已經安裝的佈景主題" builtinThemes: "標準佈景主題" + instanceTheme: "伺服器的主題" alreadyInstalled: "已安裝此佈景主題" invalid: "佈景主題格式錯誤" make: "製作佈景主題" @@ -1999,7 +2135,6 @@ _theme: header: "標題" navBg: "側邊欄的背景 " navFg: "側邊欄的文字" - navHoverFg: "側邊欄文字(懸浮) " navActive: "側邊欄文字(活動)" navIndicator: "側邊欄指示符" link: "連結" @@ -2022,17 +2157,15 @@ _theme: buttonHoverBg: "按鈕背景 (漂浮)" inputBorder: "輸入框邊框" driveFolderBg: "雲端硬碟文件夾背景" - wallpaperOverlay: "壁紙覆蓋層" badge: "徽章" messageBg: "私訊背景" - accentDarken: "強調色(黑暗)" - accentLighten: "強調色(明亮)" fgHighlighted: "突顯文字" _sfx: note: "貼文" noteMy: "我的貼文" notification: "通知" reaction: "選擇反應時" + chatMessage: "聊天訊息" _soundSettings: driveFile: "使用雲端硬碟的音效檔案" driveFileWarn: "請選擇雲端硬碟中的檔案" @@ -2076,11 +2209,11 @@ _2fa: setupCompleted: "設定完成" step4: "從現在開始,任何登入操作都將要求您提供權杖。" securityKeyNotSupported: "您的瀏覽器不支援安全金鑰。" - registerTOTPBeforeKey: "如要註冊安全金鑰或 Passkey,請先設定驗證應用程式。" - securityKeyInfo: "您可以設定使用支援 FIDO2 的硬體安全金鑰,以及裝置上的生物辨識、PIN 碼和密碼等來登入。" - registerSecurityKey: "註冊安全金鑰或 Passkey" + registerTOTPBeforeKey: "如要註冊安全金鑰或通行金鑰,請先設定驗證應用程式。" + securityKeyInfo: "註冊 WebAuthn 衍生的金鑰,例如支援 FIDO2 的硬體安全金鑰、裝置生物識別、PIN 鎖和通行金鑰。" + registerSecurityKey: "註冊安全金鑰或通行金鑰" securityKeyName: "輸入金鑰名稱" - tapSecurityKey: "按照瀏覽器的說明註冊安全金鑰或 Passkey。" + tapSecurityKey: "按照瀏覽器的說明註冊安全金鑰或通行金鑰。" removeKey: "刪除安全金鑰" removeKeyConfirm: "要刪除{name}嗎?" whyTOTPOnlyRenew: "如果註冊了安全金鑰,則無法解除驗證應用程式的設定。" @@ -2179,6 +2312,8 @@ _permissions: "read:clip-favorite": "查看摘錄的讚" "read:federation": "查看站台聯邦的相關資訊" "write:report-abuse": "檢舉違規行為" + "write:chat": "撰寫或刪除訊息" + "read:chat": "查看聊天訊息" _auth: shareAccessTitle: "應用程式的存取權限" shareAccess: "要授權「“{name}”」存取您的帳戶嗎?" @@ -2237,6 +2372,7 @@ _widgets: chooseList: "選擇清單" clicker: "點擊器" birthdayFollowings: "今天生日的使用者" + chat: "聊天" _cw: hide: "隱藏" show: "顯示內容" @@ -2302,7 +2438,7 @@ _profile: avatarDecorationMax: "最多可以設置 {max} 個裝飾。" followedMessage: "被追隨時的訊息" followedMessageDescription: "可以設定被追隨時顯示給對方的訊息。" - followedMessageDescriptionForLockedAccount: "如果追隨是需要審核的話,在允許追隨請求之後顯示。" + followedMessageDescriptionForLockedAccount: "如果追隨需要核准的話,將在通過追隨請求之後顯示。" _exportOrImport: allNotes: "所有貼文" favoritedNotes: "「我的最愛」貼文" @@ -2408,7 +2544,7 @@ _pages: note: "嵌式貼文" _note: id: "貼文ID" - idDescription: "您也可以粘貼筆記 URL 並進行設置。 " + idDescription: "您也可以貼上貼文 URL 來進行設定。 " detailed: "顯示詳細內容" _relayStatus: requesting: "等待核准" @@ -2422,11 +2558,12 @@ _notification: youRenoted: "{name} 轉發了你的貼文" youWereFollowed: "您有新的追隨者" youReceivedFollowRequest: "您有新的追隨請求" - yourFollowRequestAccepted: "您的追隨請求已通過" + yourFollowRequestAccepted: "您的追隨請求已被核准" pollEnded: "問卷調查已產生結果" newNote: "新的貼文" unreadAntennaNote: "天線 {name}" roleAssigned: "已授予角色" + chatRoomInvitationReceived: "您被邀請加入聊天室" emptyPushNotificationMessage: "推送通知已更新" achievementEarned: "獲得成就" testNotification: "通知測試" @@ -2440,6 +2577,8 @@ _notification: flushNotification: "重置通知歷史紀錄" exportOfXCompleted: "{x} 的匯出已完成。" login: "已登入" + createToken: "已產生存取權杖" + createTokenDescription: "如果您不知道,請透過「{text}」刪除存取權杖。" _types: all: "全部 " note: "使用者的最新貼文" @@ -2453,9 +2592,11 @@ _notification: receiveFollowRequest: "已收到追隨請求" followRequestAccepted: "追隨請求已接受" roleAssigned: "已授予角色" + chatRoomInvitationReceived: "已被邀請加入聊天室" achievementEarned: "獲得成就" exportCompleted: "已完成匯出。" login: "登入" + createToken: "建立存取權杖" test: "通知測試" app: "應用程式通知" _actions: @@ -2465,6 +2606,9 @@ _notification: _deck: alwaysShowMainColumn: "總是顯示主欄" columnAlign: "對齊欄位" + columnGap: "欄與欄之間的邊距" + deckMenuPosition: "多欄模式的選單位置" + navbarPosition: "導覽列位置" addColumn: "新增欄位" newNoteNotificationSettings: "新貼文通知的設定" configureColumn: "欄位的設定" @@ -2483,6 +2627,7 @@ _deck: useSimpleUiForNonRootPages: "用簡易介面顯示非根頁面" usedAsMinWidthWhenFlexible: "如果啟用「自動調整寬度」,此為最小寬度" flexible: "自動調整寬度" + enableSyncBetweenDevicesForProfiles: "啟用裝置與裝置之間的設定檔資料同步化" _columns: main: "主列" widgets: "小工具" @@ -2494,6 +2639,7 @@ _deck: mentions: "提及" direct: "指定使用者" roleTimeline: "角色時間軸" + chat: "聊天" _dialog: charactersExceeded: "您的貼文太長了!現時字數 {current}/限制字數 {max}" charactersBelow: "您的貼文太短了!現時字數 {current}/限制字數 {min}" @@ -2590,6 +2736,8 @@ _moderationLogTypes: deletePage: "刪除頁面" deleteFlash: "刪除 Play" deleteGalleryPost: "刪除相簿的貼文" + deleteChatRoom: "刪除聊天室" + updateProxyAccountDescription: "更新代理帳戶的說明" _fileViewer: title: "檔案詳細資訊" type: "檔案類型 " @@ -2603,10 +2751,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "安裝前請確認提供者是可信賴的。" _plugin: title: "要安裝此外掛嘛?" - metaTitle: "外掛資訊" _theme: title: "要安裝此佈景主題嗎?" - metaTitle: "佈景主題資訊" _meta: base: "基本配色方案" _vendorInfo: @@ -2655,7 +2801,7 @@ _dataSaver: _hemisphere: N: "北半球" S: "南半球" - caption: "在某些客戶端的設定中,用於判斷季節。" + caption: "某些客戶端的設定會用此來判斷季節。" _reversi: reversi: "黑白棋" gameSettings: "對弈設定" @@ -2822,8 +2968,6 @@ _remoteLookupErrors: _responseInvalid: title: "回應不正確" description: "雖然能夠與這個伺服器通訊,但是取得的資料不正確。" - _responseInvalidIdHostNotMatch: - description: "輸入的 URI 的網域與最終取得的 URI 的網域不同。 如果您是透過第三方伺服器查詢遠端內容,請使用可在原始伺服器上取得的 URI 再次查詢。" _noSuchObject: title: "查無項目" description: "無法找到所要求的資源,請再次檢查 URI。" @@ -2840,3 +2984,23 @@ _captcha: _unknown: title: "CAPTCHA 錯誤" text: "發生了意外的錯誤。" +_bootErrors: + title: "載入失敗" + serverError: "如果稍等片刻並重新載入後問題仍然存在,請聯絡您的伺服器管理員並提供以下的錯誤 ID。" + solution: "執行以下操作或許可以解決問題。" + solution1: "將瀏覽器和作業系統更新至最新版本" + solution2: "停用廣告攔截器" + solution3: "清除瀏覽器的快取" + solution4: "(Tor 瀏覽器)將 dom.webaudio.enabled 設為 true" + otherOption: "其他選項" + otherOption1: "刪除用戶端設定和快取" + otherOption2: "啟動簡易用戶端" + otherOption3: "啟動修復工具" +_search: + searchScopeAll: "全部" + searchScopeLocal: "本地" + searchScopeServer: "指定伺服器" + searchScopeUser: "指定使用者" + pleaseEnterServerHost: "請輸入伺服器的主機名稱" + pleaseSelectUser: "請選擇使用者" + serverHostPlaceholder: "例:misskey.example.com" diff --git a/package.json b/package.json index a29b37f216..24777d9d87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sharkey", - "version": "2025.2.3", + "version": "2025.4.3", "codename": "shonk", "repository": { "type": "git", @@ -27,9 +27,9 @@ "start": "pnpm check:connect && cd packages/backend && MK_WARNED_ABOUT_CONFIG=true node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", - "migrate": "cd packages/backend && pnpm migrate", - "revert": "cd packages/backend && pnpm revert", - "check:connect": "cd packages/backend && pnpm check:connect", + "migrate": "pnpm --filter backend migrate", + "revert": "pnpm --filter backend revert", + "check:connect": "pnpm --filter backend check:connect", "migrateandstart": "pnpm migrate && pnpm start", "watch": "pnpm dev", "dev": "node scripts/dev.mjs", @@ -40,7 +40,7 @@ "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:run": "pnpm cypress run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", - "e2e-dev-container": "cp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run", + "e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run", "jest": "cd packages/backend && pnpm jest", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", "test": "pnpm -r test", @@ -50,34 +50,35 @@ "cleanall": "pnpm clean-all" }, "resolutions": { - "chokidar": "3.5.3", + "chokidar": "4.0.3", "lodash": "4.17.21" }, "dependencies": { - "cssnano": "6.1.2", - "execa": "8.0.1", - "fast-glob": "3.3.2", - "ignore-walk": "6.0.5", - "js-yaml": "4.1.0", - "postcss": "8.4.49", - "tar": "6.2.1", - "terser": "5.36.0", - "typescript": "5.6.3", - "esbuild": "0.24.0", - "glob": "11.0.0" + "js-yaml": "4.1.0" }, "optionalDependencies": { - "cypress": "13.15.2" + "cypress": "14.3.2" }, "devDependencies": { - "@misskey-dev/eslint-plugin": "2.0.3", - "@types/node": "22.9.0", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", + "@misskey-dev/eslint-plugin": "2.1.0", + "@types/node": "22.15.2", + "@typescript-eslint/eslint-plugin": "8.31.0", + "@typescript-eslint/parser": "8.31.0", "cross-env": "7.0.3", - "eslint": "9.14.0", - "globals": "15.12.0", + "cssnano": "7.0.6", + "esbuild": "0.25.3", + "eslint": "9.25.1", + "execa": "9.5.2", + "fast-glob": "3.3.3", + "glob": "11.0.2", + "globals": "16.1.0", "ncp": "2.0.0", - "start-server-and-test": "2.0.8" + "pnpm": "9.6.0", + "ignore-walk": "7.0.0", + "postcss": "8.5.3", + "start-server-and-test": "2.0.11", + "tar": "7.4.3", + "terser": "5.39.0", + "typescript": "5.8.3" } } diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index 845190b5f4..f4bf7a4d2a 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -1,5 +1,5 @@ { - "$schema": "https://json.schemastore.org/swcrc", + "$schema": "https://swc.rs/schema.json", "jsc": { "parser": { "syntax": "typescript", diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index 7c649a373c..ef5a0ccec1 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -42,13 +42,6 @@ export default [ name: '__filename', message: 'Not in ESModule. Use `import.meta.url` instead.', }], - // https://typescript-eslint.io/rules/prefer-nullish-coalescing/ - '@typescript-eslint/prefer-nullish-coalescing': ['warn', { - ignorePrimitives: { - // Without this, the rule breaks for nullable booleans - boolean: true, - }, - }], }, }, { diff --git a/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js b/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js new file mode 100644 index 0000000000..74225de96a --- /dev/null +++ b/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAntennaHideNotesInSensitiveChannel1736230492103 { + name = 'AddAntennaHideNotesInSensitiveChannel1736230492103' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" ADD "hideNotesInSensitiveChannel" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "hideNotesInSensitiveChannel"`); + } +} diff --git a/packages/backend/migration/1740121393164-system-accounts.js b/packages/backend/migration/1740121393164-system-accounts.js new file mode 100644 index 0000000000..9490cb2b64 --- /dev/null +++ b/packages/backend/migration/1740121393164-system-accounts.js @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts1740121393164 { + name = 'SystemAccounts1740121393164' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "system_account" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "type" character varying(256) NOT NULL, CONSTRAINT "PK_edb56f4aaf9ddd50ee556da97ba" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_41a3c87a37aea616ee459369e1" ON "system_account" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_c362033aee0ea51011386a5a7e" ON "system_account" ("type") `); + await queryRunner.query(`ALTER TABLE "system_account" ADD CONSTRAINT "FK_41a3c87a37aea616ee459369e12" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + + const instanceActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'instance.actor'`); + if (instanceActor.length > 0) { + await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${instanceActor[0].id}', '${instanceActor[0].id}', 'actor')`); + } + + const relayActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'relay.actor'`); + if (relayActor.length > 0) { + await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${relayActor[0].id}', '${relayActor[0].id}', 'relay')`); + } + + const meta = await queryRunner.query(`SELECT "proxyAccountId" FROM "meta" ORDER BY "id" DESC LIMIT 1`); + if (!meta && meta.length >= 1 && meta[0].proxyAccountId) { + await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${meta[0].proxyAccountId}', '${meta[0].proxyAccountId}', 'proxy')`); + } + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "system_account" DROP CONSTRAINT "FK_41a3c87a37aea616ee459369e12"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c362033aee0ea51011386a5a7e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_41a3c87a37aea616ee459369e1"`); + await queryRunner.query(`DROP TABLE "system_account"`); + } +} diff --git a/packages/backend/migration/1740129169650-system-accounts-2.js b/packages/backend/migration/1740129169650-system-accounts-2.js new file mode 100644 index 0000000000..c03f0337ab --- /dev/null +++ b/packages/backend/migration/1740129169650-system-accounts-2.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts21740129169650 { + name = 'SystemAccounts21740129169650' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "proxyAccountId"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "proxyAccountId" character varying(32)`); + const proxyAccountId = await queryRunner.query(`SELECT "userId" FROM "system_account" WHERE "type" = 'proxy' ORDER BY "id" DESC LIMIT 1`); + if (proxyAccountId && proxyAccountId.length >= 1) { + await queryRunner.query(`UPDATE "meta" SET "proxyAccountId" = '${proxyAccountId[0].userId}'`); + } + await queryRunner.query(`ALTER TABLE "meta" ADD CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad" FOREIGN KEY ("proxyAccountId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/migration/1740133121105-system-accounts-3.js b/packages/backend/migration/1740133121105-system-accounts-3.js new file mode 100644 index 0000000000..a1f8c996f5 --- /dev/null +++ b/packages/backend/migration/1740133121105-system-accounts-3.js @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts31740133121105 { + name = 'SystemAccounts31740133121105' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "rootUserId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "meta" ADD CONSTRAINT "FK_c80e4079d632f95eac06a9d28cc" FOREIGN KEY ("rootUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + + const users = await queryRunner.query(`SELECT "id" FROM "user" WHERE "isRoot" = true LIMIT 1`); + if (users.length > 0) { + await queryRunner.query(`UPDATE "meta" SET "rootUserId" = $1`, [users[0].id]); + } + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP CONSTRAINT "FK_c80e4079d632f95eac06a9d28cc"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "rootUserId"`); + } +} diff --git a/packages/backend/migration/1740162088574-add_unsignedFetch.js b/packages/backend/migration/1740162088574-add_unsignedFetch.js new file mode 100644 index 0000000000..0744e68dfa --- /dev/null +++ b/packages/backend/migration/1740162088574-add_unsignedFetch.js @@ -0,0 +1,35 @@ +import { loadConfig } from '../built/config.js'; + +export class AddUnsignedFetch1740162088574 { + name = 'AddUnsignedFetch1740162088574' + + async up(queryRunner) { + // meta.allowUnsignedFetch + await queryRunner.query(`CREATE TYPE "public"."meta_allowunsignedfetch_enum" AS ENUM('never', 'always', 'essential')`); + await queryRunner.query(`ALTER TABLE "meta" ADD "allowUnsignedFetch" "public"."meta_allowunsignedfetch_enum" NOT NULL DEFAULT 'always'`); + + // user.allowUnsignedFetch + await queryRunner.query(`CREATE TYPE "public"."user_allowunsignedfetch_enum" AS ENUM('never', 'always', 'essential', 'staff')`); + await queryRunner.query(`ALTER TABLE "user" ADD "allowUnsignedFetch" "public"."user_allowunsignedfetch_enum" NOT NULL DEFAULT 'staff'`); + + // Special one-time migration: allow unauthorized fetch for system accounts + await queryRunner.query(`UPDATE "user" SET "allowUnsignedFetch" = 'always' WHERE "username" LIKE '%.%' AND "host" IS null`); + + // Special one-time migration: convert legacy config "" to meta setting "" + const config = await loadConfig(); + if (config.checkActivityPubGetSignature) { + // noinspection SqlWithoutWhere + await queryRunner.query(`UPDATE "meta" SET "allowUnsignedFetch" = 'never'`); + } + } + + async down(queryRunner) { + // user.allowUnsignedFetch + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "allowUnsignedFetch"`); + await queryRunner.query(`DROP TYPE "public"."user_allowunsignedfetch_enum"`); + + // meta.allowUnsignedFetch + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowUnsignedFetch"`); + await queryRunner.query(`DROP TYPE "public"."meta_allowunsignedfetch_enum"`); + } +} diff --git a/packages/backend/migration/1740993126937-system-accounts-4.js b/packages/backend/migration/1740993126937-system-accounts-4.js new file mode 100644 index 0000000000..83654aca80 --- /dev/null +++ b/packages/backend/migration/1740993126937-system-accounts-4.js @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts41740993126937 { + name = 'SystemAccounts41740993126937' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isRoot"`); + } + + async down(queryRunner) { + // down 実行時は isRoot = true のユーザーが存在しなくなるため手動で対応する必要あり + await queryRunner.query(`ALTER TABLE "user" ADD "isRoot" boolean NOT NULL DEFAULT false`); + } +} diff --git a/packages/backend/migration/1741215877000-libetranslate.js b/packages/backend/migration/1741215877000-libetranslate.js new file mode 100644 index 0000000000..a2345ea5a4 --- /dev/null +++ b/packages/backend/migration/1741215877000-libetranslate.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Libetranslate1741215877000 { + name = 'Libretranslate1741215877000'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "libreTranslateURL" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "libreTranslateKey" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "libreTranslateURL"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "libreTranslateKey"`); + } +} diff --git a/packages/backend/migration/1741279404074-system-accounts-fixup.js b/packages/backend/migration/1741279404074-system-accounts-fixup.js new file mode 100644 index 0000000000..31cab7f5ae --- /dev/null +++ b/packages/backend/migration/1741279404074-system-accounts-fixup.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts1741279404074 { + name = 'SystemAccounts1741279404074' + + async up(queryRunner) { + const instanceActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'instance.actor' AND "host" IS NULL AND "id" NOT IN (SELECT "userId" FROM "system_account" WHERE "type" = 'actor')`); + if (instanceActor.length > 0) { + console.warn('instance.actor was incorrect, updating...'); + await queryRunner.query(`UPDATE "system_account" SET "id" = '${instanceActor[0].id}', "userId" = '${instanceActor[0].id}' WHERE "type" = 'actor'`); + } + + const relayActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'relay.actor' AND "host" IS NULL AND "id" NOT IN (SELECT "userId" FROM "system_account" WHERE "type" = 'relay')`); + if (relayActor.length > 0) { + console.warn('relay.actor was incorrect, updating...'); + await queryRunner.query(`UPDATE "system_account" SET "id" = '${relayActor[0].id}', "userId" = '${relayActor[0].id}' WHERE "type" = 'relay'`); + } + } + + async down(queryRunner) { + // fixup migration, no down migration + } +} diff --git a/packages/backend/migration/1741424411879-user-featured-fixup.js b/packages/backend/migration/1741424411879-user-featured-fixup.js new file mode 100644 index 0000000000..5643a328f0 --- /dev/null +++ b/packages/backend/migration/1741424411879-user-featured-fixup.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UserFeaturedFixup1741424411879 { + name = 'UserFeaturedFixup1741424411879' + + async up(queryRunner) { + await queryRunner.query(`CREATE OR REPLACE FUNCTION pg_temp.extract_ap_id(text) RETURNS text AS $$ + SELECT + CASE + WHEN $1 ~ '^https?://' THEN $1 + WHEN $1 LIKE '{%' THEN COALESCE(jsonb_extract_path_text($1::jsonb, 'id'), null) + ELSE null + END; + $$ LANGUAGE sql IMMUTABLE;`); + + // "host" is NOT NULL is not needed but just in case add it to prevent overwriting irreplaceable data + await queryRunner.query(`UPDATE "user" SET "featured" = pg_temp.extract_ap_id("featured") WHERE "host" IS NOT NULL`); + } + + async down(queryRunner) { + // fixup migration, no down migration + } +} diff --git a/packages/backend/migration/1742203321812-chat.js b/packages/backend/migration/1742203321812-chat.js new file mode 100644 index 0000000000..3d8f7276b5 --- /dev/null +++ b/packages/backend/migration/1742203321812-chat.js @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat1742203321812 { + name = 'Chat1742203321812' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_room" ("id" character varying(32) NOT NULL, "name" character varying(256) NOT NULL, "ownerId" character varying(32) NOT NULL, CONSTRAINT "PK_8aa3a52cf74c96469f0ef9fbe3e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_f0d8ad64243fa2ca2800da0dfd" ON "chat_room" ("ownerId") `); + await queryRunner.query(`CREATE TABLE "chat_message" ("id" character varying(32) NOT NULL, "fromUserId" character varying(32) NOT NULL, "toUserId" character varying(32), "toRoomId" character varying(32), "text" character varying(4096), "uri" character varying(512), "reads" character varying(32) array NOT NULL DEFAULT '{}', "fileId" character varying(32), "reactions" character varying(1024) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_3cc0d85193aade457d3077dd06b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_79a26e7a4d9afa5e4fc05f134e" ON "chat_message" ("fromUserId") `); + await queryRunner.query(`CREATE INDEX "IDX_25e097b51d7622c249452c6f75" ON "chat_message" ("toUserId") `); + await queryRunner.query(`CREATE INDEX "IDX_f006b8a76efd1abf9f221c175c" ON "chat_message" ("toRoomId") `); + await queryRunner.query(`CREATE TABLE "chat_room_membership" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_2bd59c741e571b283c048beb69a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_d99c5279460fb77ef58c596ce5" ON "chat_room_membership" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_c25143ebab714e930aeca1c0e8" ON "chat_room_membership" ("roomId") `); + await queryRunner.query(`ALTER TABLE "chat_room" ADD CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed" FOREIGN KEY ("fromUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_25e097b51d7622c249452c6f757" FOREIGN KEY ("toUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce" FOREIGN KEY ("toRoomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_d99c5279460fb77ef58c596ce51" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d"`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_d99c5279460fb77ef58c596ce51"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_25e097b51d7622c249452c6f757"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c25143ebab714e930aeca1c0e8"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d99c5279460fb77ef58c596ce5"`); + await queryRunner.query(`DROP TABLE "chat_room_membership"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f006b8a76efd1abf9f221c175c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_25e097b51d7622c249452c6f75"`); + await queryRunner.query(`DROP INDEX "public"."IDX_79a26e7a4d9afa5e4fc05f134e"`); + await queryRunner.query(`DROP TABLE "chat_message"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f0d8ad64243fa2ca2800da0dfd"`); + await queryRunner.query(`DROP TABLE "chat_room"`); + } +} diff --git a/packages/backend/migration/1742608337548-chat-2.js b/packages/backend/migration/1742608337548-chat-2.js new file mode 100644 index 0000000000..9f74a263d6 --- /dev/null +++ b/packages/backend/migration/1742608337548-chat-2.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat21742608337548 { + name = 'Chat21742608337548' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "chatScope" character varying(128) NOT NULL DEFAULT 'mutual'`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_185b6b5afa707b5d36d1ce3144" ON "chat_room_membership" ("userId", "roomId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_185b6b5afa707b5d36d1ce3144"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "chatScope"`); + } +} diff --git a/packages/backend/migration/1742617546147-chat-3.js b/packages/backend/migration/1742617546147-chat-3.js new file mode 100644 index 0000000000..116b9a738b --- /dev/null +++ b/packages/backend/migration/1742617546147-chat-3.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat31742617546147 { + name = 'Chat31742617546147' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_approval" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "otherId" character varying(32) NOT NULL, CONSTRAINT "PK_fbbb95d60acf5c85388345b5f5d" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_530257863e1381a7f2f1d3282f" ON "chat_approval" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_b1d46037f23d170da5c05fdf75" ON "chat_approval" ("otherId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_12c4768a2f706fc267f2078903" ON "chat_approval" ("userId", "otherId") `); + await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_530257863e1381a7f2f1d3282fe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_b1d46037f23d170da5c05fdf755" FOREIGN KEY ("otherId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_b1d46037f23d170da5c05fdf755"`); + await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_530257863e1381a7f2f1d3282fe"`); + await queryRunner.query(`DROP INDEX "public"."IDX_12c4768a2f706fc267f2078903"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1d46037f23d170da5c05fdf75"`); + await queryRunner.query(`DROP INDEX "public"."IDX_530257863e1381a7f2f1d3282f"`); + await queryRunner.query(`DROP TABLE "chat_approval"`); + } +} diff --git a/packages/backend/migration/1742707840715-chat-4.js b/packages/backend/migration/1742707840715-chat-4.js new file mode 100644 index 0000000000..953a53d880 --- /dev/null +++ b/packages/backend/migration/1742707840715-chat-4.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat41742707840715 { + name = 'Chat41742707840715' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_room_invitation" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_9d489521a312dd28225672de2dc" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_8552bb38e7ed038c5bdd398a38" ON "chat_room_invitation" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_5f265075b215fc390a57523b12" ON "chat_room_invitation" ("roomId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_044f2a7962b8ee5bbfaa02e8a3" ON "chat_room_invitation" ("userId", "roomId") `); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_5f265075b215fc390a57523b12a" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_5f265075b215fc390a57523b12a"`); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384"`); + await queryRunner.query(`DROP INDEX "public"."IDX_044f2a7962b8ee5bbfaa02e8a3"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5f265075b215fc390a57523b12"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8552bb38e7ed038c5bdd398a38"`); + await queryRunner.query(`DROP TABLE "chat_room_invitation"`); + } +} diff --git a/packages/backend/migration/1742721896936-chat-5.js b/packages/backend/migration/1742721896936-chat-5.js new file mode 100644 index 0000000000..00db787cb7 --- /dev/null +++ b/packages/backend/migration/1742721896936-chat-5.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat51742721896936 { + name = 'Chat51742721896936' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD "ignored" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP COLUMN "ignored"`); + } +} diff --git a/packages/backend/migration/1742795111958-chat-6.js b/packages/backend/migration/1742795111958-chat-6.js new file mode 100644 index 0000000000..9a5dc3e32f --- /dev/null +++ b/packages/backend/migration/1742795111958-chat-6.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat61742795111958 { + name = 'Chat61742795111958' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room" ADD "description" character varying(2048) NOT NULL DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "chat_room" ADD "isArchived" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD "isMuted" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP COLUMN "isMuted"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "isArchived"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "description"`); + } +} diff --git a/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js b/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js new file mode 100644 index 0000000000..ff4f7a051b --- /dev/null +++ b/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RoleCopyOnMoveAccount1743558299182 { + name = 'RoleCopyOnMoveAccount1743558299182' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "preserveAssignmentOnMoveAccount" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "preserveAssignmentOnMoveAccount"`); + } +} diff --git a/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js b/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js new file mode 100644 index 0000000000..1e8faafbc4 --- /dev/null +++ b/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ExcludeNotesInSensitiveChannel1744075766000 { + name = 'ExcludeNotesInSensitiveChannel1744075766000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" RENAME COLUMN "hideNotesInSensitiveChannel" TO "excludeNotesInSensitiveChannel"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" RENAME COLUMN "excludeNotesInSensitiveChannel" TO "hideNotesInSensitiveChannel"`); + } +} diff --git a/packages/backend/migration/1745378064470-composite-note-index.js b/packages/backend/migration/1745378064470-composite-note-index.js new file mode 100644 index 0000000000..49e835d38c --- /dev/null +++ b/packages/backend/migration/1745378064470-composite-note-index.js @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CompositeNoteIndex1745378064470 { + name = 'CompositeNoteIndex1745378064470'; + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`); + // Flush all cached Linear Scan Plans and redo statistics for composite index + // this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly + await queryRunner.query(`ANALYZE "user", "note"`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); + await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); + } +} diff --git a/packages/backend/migration/1746029830779-add_meta-enableProxyAccount.js b/packages/backend/migration/1746029830779-add_meta-enableProxyAccount.js new file mode 100644 index 0000000000..2a6aac96af --- /dev/null +++ b/packages/backend/migration/1746029830779-add_meta-enableProxyAccount.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddMetaEnableProxyAccount1746029830779 { + name = 'AddMetaEnableProxyAccount1746029830779' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableProxyAccount" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableProxyAccount"`); + } +} diff --git a/packages/backend/migration/1746813431123756-user-distinct-null.js b/packages/backend/migration/1746813431123756-user-distinct-null.js new file mode 100644 index 0000000000..407c91067a --- /dev/null +++ b/packages/backend/migration/1746813431123756-user-distinct-null.js @@ -0,0 +1,13 @@ +export class IndexUserNullDistinct1746813431756 { + name = 'Indexusernulldistinct1746813431756' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5deb01ae162d1d70b80d064c27"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5deb01ae162d1d70b80d064c27" ON "user" ("usernameLower", "host") NULLS NOT DISTINCT`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5deb01ae162d1d70b80d064c27"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5deb01ae162d1d70b80d064c27" ON "user" ("usernameLower", "host") `); + } +} diff --git a/packages/backend/migration/1747023091463-add_meta_translationTimeout.js b/packages/backend/migration/1747023091463-add_meta_translationTimeout.js new file mode 100644 index 0000000000..cf291f8d74 --- /dev/null +++ b/packages/backend/migration/1747023091463-add_meta_translationTimeout.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddMetaTranslationTimeout1747023091463 { + name = 'AddMetaTranslationTimeout1747023091463' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "translationTimeout" integer NOT NULL DEFAULT '5000'`); + await queryRunner.query(`COMMENT ON COLUMN "meta"."translationTimeout" IS 'Timeout in milliseconds for translation API requests'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "meta"."translationTimeout" IS 'Timeout in milliseconds for translation API requests'`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "translationTimeout"`); + } +} diff --git a/packages/backend/migration/1747934911491-rename_followingVisibility.js b/packages/backend/migration/1747934911491-rename_followingVisibility.js new file mode 100644 index 0000000000..75f6db49ec --- /dev/null +++ b/packages/backend/migration/1747934911491-rename_followingVisibility.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RenameFollowingVisibility1747934911491 { + name = 'RenameFollowingVisibility1747934911491' + + async up(queryRunner) { + await queryRunner.query(`ALTER TYPE "public"."user_profile_followingvisibility_enum" RENAME TO "user_profile_followingVisibility_enum"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TYPE "public"."user_profile_followingVisibility_enum" RENAME TO "user_profile_followingvisibility_enum"`); + } +} diff --git a/packages/backend/migration/1747935197708-add_entity_comments.js b/packages/backend/migration/1747935197708-add_entity_comments.js new file mode 100644 index 0000000000..687c957425 --- /dev/null +++ b/packages/backend/migration/1747935197708-add_entity_comments.js @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddEntityComments1747935197708 { + name = 'AddEntityComments1747935197708' + + async up(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."backgroundId" IS 'The ID of background DriveFile.'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isSilenced" IS 'Whether the User is silenced.'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."noindex" IS 'Whether the User''s notes dont get indexed.'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."speakAsCat" IS 'Whether the User speaks in nya.'`); + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."listenbrainz" IS 'The ListenBrainz username of the User.'`); + await queryRunner.query(`COMMENT ON COLUMN "note"."updatedAt" IS 'The update time of the Note.'`); + await queryRunner.query(`COMMENT ON COLUMN "meta"."trustedLinkUrlPatterns" IS 'An array of URL strings or regex that can be used to omit warnings about redirects to external sites. Separate them with spaces to specify AND, and enclose them with slashes to specify regular expressions. Each item is regarded as an OR.'`); + await queryRunner.query(`COMMENT ON COLUMN "note_edit"."oldDate" IS 'The old date from before the edit'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "note_edit"."oldDate" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "meta"."trustedLinkUrlPatterns" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."updatedAt" IS 'The updated date of the Note.'`); + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."listenbrainz" IS 'listenbrainz username to fetch currently playing.'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."speakAsCat" IS 'Whether to speak as a cat if chosen.'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."noindex" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isSilenced" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."backgroundId" IS NULL`); + } +} diff --git a/packages/backend/migration/1747937504140-fix-system_webhook-updatedAt-default.js b/packages/backend/migration/1747937504140-fix-system_webhook-updatedAt-default.js new file mode 100644 index 0000000000..d30c6c4872 --- /dev/null +++ b/packages/backend/migration/1747937504140-fix-system_webhook-updatedAt-default.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixSystemWebhookUpdatedAtDefault1747937504140 { + name = 'FixSystemWebhookUpdatedAtDefault1747937504140' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT now()`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`); + } +} diff --git a/packages/backend/migration/1747937670341-fix-abuse_report_notification_recipient-updatedAt-default.js b/packages/backend/migration/1747937670341-fix-abuse_report_notification_recipient-updatedAt-default.js new file mode 100644 index 0000000000..4364bbb56d --- /dev/null +++ b/packages/backend/migration/1747937670341-fix-abuse_report_notification_recipient-updatedAt-default.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixAbuseReportNotificationRecipientUpdatedAtDefault1747937670341 { + name = 'FixAbuseReportNotificationRecipientUpdatedAtDefault1747937670341' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT now()`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`); + } +} diff --git a/packages/backend/migration/1747937796573-fix-flash-visibility-nullable.js b/packages/backend/migration/1747937796573-fix-flash-visibility-nullable.js new file mode 100644 index 0000000000..7076d05e19 --- /dev/null +++ b/packages/backend/migration/1747937796573-fix-flash-visibility-nullable.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixFlashVisibilityNullable1747937796573 { + name = 'FixFlashVisibilityNullable1747937796573' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" SET NOT NULL`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" DROP NOT NULL`); + } +} diff --git a/packages/backend/migration/1747938136399-fix-abuse_report_notification_recipient-defaults.js b/packages/backend/migration/1747938136399-fix-abuse_report_notification_recipient-defaults.js new file mode 100644 index 0000000000..6cf48bcbe9 --- /dev/null +++ b/packages/backend/migration/1747938136399-fix-abuse_report_notification_recipient-defaults.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixAbuseReportNotificationRecipientDefaults1747938136399 { + name = 'FixAbuseReportNotificationRecipientDefaults1747938136399' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "userId" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "systemWebhookId" DROP DEFAULT`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "systemWebhookId" SET DEFAULT NULL`); + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "userId" SET DEFAULT NULL`); + } +} diff --git a/packages/backend/migration/1747938263980-fix-meta-urlPreviewUserAgent-default.js b/packages/backend/migration/1747938263980-fix-meta-urlPreviewUserAgent-default.js new file mode 100644 index 0000000000..d04c1ac377 --- /dev/null +++ b/packages/backend/migration/1747938263980-fix-meta-urlPreviewUserAgent-default.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixMetaUrlPreviewUserAgentDefault1747938263980 { + name = 'FixMetaUrlPreviewUserAgentDefault1747938263980' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "urlPreviewUserAgent" DROP DEFAULT`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "urlPreviewUserAgent" SET DEFAULT NULL`); + } +} diff --git a/packages/backend/migration/1747938628395-add-missing-indexes.js b/packages/backend/migration/1747938628395-add-missing-indexes.js new file mode 100644 index 0000000000..0229a6c898 --- /dev/null +++ b/packages/backend/migration/1747938628395-add-missing-indexes.js @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddMissingIndexes1747938628395 { + name = 'AddMissingIndexes1747938628395' + + async up(queryRunner) { + // Some instances have duplicate list entries + await queryRunner.query(` + DELETE FROM "user_list_membership" + WHERE "id" NOT IN ( + SELECT MIN("id") + FROM "user_list_membership" + GROUP BY "userId", "userListId" + )`); + + // Some instances already have these indexes, for an unknown reason + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_e4f3094c43f2d665e6030b0337"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_cddcaf418dc4d392ecfcca842a"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_021015e6683570ae9f6b0c62be"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_58699f75b9cf904f5f007909cb"`); + + // Now the actual migration + await queryRunner.query(`CREATE INDEX "IDX_58699f75b9cf904f5f007909cb" ON "user_profile" ("birthday") `); + await queryRunner.query(`CREATE INDEX "IDX_021015e6683570ae9f6b0c62be" ON "user_list_membership" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_cddcaf418dc4d392ecfcca842a" ON "user_list_membership" ("userListId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e4f3094c43f2d665e6030b0337" ON "user_list_membership" ("userId", "userListId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_e4f3094c43f2d665e6030b0337"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cddcaf418dc4d392ecfcca842a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_021015e6683570ae9f6b0c62be"`); + await queryRunner.query(`DROP INDEX "public"."IDX_58699f75b9cf904f5f007909cb"`); + } +} diff --git a/packages/backend/migration/1747944466178-alter-meta-defaultLike-not-null.js b/packages/backend/migration/1747944466178-alter-meta-defaultLike-not-null.js new file mode 100644 index 0000000000..b206a15ee2 --- /dev/null +++ b/packages/backend/migration/1747944466178-alter-meta-defaultLike-not-null.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AlterMetaDefaultLikeNotNull1747944466178 { + name = 'AlterMetaDefaultLikeNotNull1747944466178' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "defaultLike" SET NOT NULL`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "defaultLike" DROP NOT NULL`); + } +} diff --git a/packages/backend/migration/1748096357260-AddAttributionDomains.js b/packages/backend/migration/1748096357260-AddAttributionDomains.js new file mode 100644 index 0000000000..0a9679bccd --- /dev/null +++ b/packages/backend/migration/1748096357260-AddAttributionDomains.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: piuvas and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAttributionDomains1748096357260 { + name = 'AddAttributionDomains1748096357260' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "attributionDomains" text array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "attributionDomains"`); + } +} diff --git a/packages/backend/migration/1748104955717-index_IDX_instance_host_key.js b/packages/backend/migration/1748104955717-index_IDX_instance_host_key.js new file mode 100644 index 0000000000..139eae740f --- /dev/null +++ b/packages/backend/migration/1748104955717-index_IDX_instance_host_key.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class IndexIDXInstanceHostKey1748104955717 { + name = 'IndexIDXInstanceHostKey1748104955717' + + async up(queryRunner) { + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_instance_host_key"`); + } +} diff --git a/packages/backend/migration/1748105111513-add_instance_block_columns.js b/packages/backend/migration/1748105111513-add_instance_block_columns.js new file mode 100644 index 0000000000..6e3d78d5e8 --- /dev/null +++ b/packages/backend/migration/1748105111513-add_instance_block_columns.js @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {{ blockedHosts: string[], silencedHosts: string[], mediaSilencedHosts: string[], federationHosts: string[], bubbleInstances: string[] }} Meta + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AddInstanceBlockColumns1748105111513 { + name = 'AddInstanceBlockColumns1748105111513' + + async up(queryRunner) { + // Schema migration + await queryRunner.query(`ALTER TABLE "instance" ADD "isBlocked" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isBlocked" IS 'True if this instance is blocked from federation.'`); + await queryRunner.query(`ALTER TABLE "instance" ADD "isAllowListed" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isAllowListed" IS 'True if this instance is allow-listed.'`); + await queryRunner.query(`ALTER TABLE "instance" ADD "isBubbled" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isBubbled" IS 'True if this instance is part of the local bubble.'`); + await queryRunner.query(`ALTER TABLE "instance" ADD "isSilenced" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isSilenced" IS 'True if this instance is silenced.'`); + await queryRunner.query(`ALTER TABLE "instance" ADD "isMediaSilenced" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."isMediaSilenced" IS 'True if this instance is media-silenced.'`); + + // Data migration + /** @type {Meta[]} */ + const metas = await queryRunner.query(`SELECT "blockedHosts", "silencedHosts", "mediaSilencedHosts", "federationHosts", "bubbleInstances" FROM "meta"`); + if (metas.length > 0) { + /** @type {Meta} */ + const meta = metas[0]; + + // Blocked hosts + if (meta.blockedHosts.length > 0) { + const patterns = buildPatterns(meta.blockedHosts); + await queryRunner.query(`UPDATE "instance" SET "isBlocked" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + + // Silenced hosts + if (meta.silencedHosts.length > 0) { + const patterns = buildPatterns(meta.silencedHosts); + await queryRunner.query(`UPDATE "instance" SET "isSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + + // Media silenced hosts + if (meta.mediaSilencedHosts.length > 0) { + const patterns = buildPatterns(meta.mediaSilencedHosts); + await queryRunner.query(`UPDATE "instance" SET "isMediaSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + + // Allow-listed hosts + if (meta.federationHosts.length > 0) { + const patterns = buildPatterns(meta.federationHosts); + await queryRunner.query(`UPDATE "instance" SET "isAllowListed" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + + // Bubbled hosts + if (meta.bubbleInstances.length > 0) { + const patterns = buildPatterns(meta.bubbleInstances); + await queryRunner.query(`UPDATE "instance" SET "isBubbled" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]); + } + } + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isMediaSilenced"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isSilenced"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBubbled"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isAllowListed"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBlocked"`); + } +} + +/** + * @param {string[]} input + * @returns {string[]} + */ +function buildPatterns(input) { + return input.map(i => i.toLowerCase().split('').reverse().join('') + '.%'); +} diff --git a/packages/backend/migration/1748128176881-add_instance_foreign_keys.js b/packages/backend/migration/1748128176881-add_instance_foreign_keys.js new file mode 100644 index 0000000000..2c2383c50f --- /dev/null +++ b/packages/backend/migration/1748128176881-add_instance_foreign_keys.js @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AddInstanceForeignKeys1748128176881 { + name = 'AddInstanceForeignKeys1748128176881' + + async up(queryRunner) { + // Fix-up: Some older instances have users without a matching instance entry + await queryRunner.query(` + INSERT INTO "instance" ("id", "host", "firstRetrievedAt") + SELECT + MIN("id"), + "host", + COALESCE(MIN("lastFetchedAt"), CURRENT_TIMESTAMP) + FROM "user" + WHERE + "host" IS NOT NULL AND + NOT EXISTS (select 1 from "instance" where "instance"."host" = "user"."host") + GROUP BY "host" + `); + + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_user_host" FOREIGN KEY ("host") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_userHost" FOREIGN KEY ("userHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_replyUserHost" FOREIGN KEY ("replyUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_renoteUserHost" FOREIGN KEY ("renoteUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_renoteUserHost"`); + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_replyUserHost"`); + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_userHost"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_user_host"`); + } +} diff --git a/packages/backend/migration/1748137683887-add_instance_foreign_keys_to_following.js b/packages/backend/migration/1748137683887-add_instance_foreign_keys_to_following.js new file mode 100644 index 0000000000..8f4a977ff5 --- /dev/null +++ b/packages/backend/migration/1748137683887-add_instance_foreign_keys_to_following.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AddInstanceForeignKeysToFollowing1748137683887 { + name = 'AddInstanceForeignKeysToFollowing1748137683887' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followerHost" FOREIGN KEY ("followerHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followeeHost" FOREIGN KEY ("followeeHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followeeHost"`); + await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followerHost"`); + } +} diff --git a/packages/backend/migration/1748191631151-analyze_instance-user-note-following.js b/packages/backend/migration/1748191631151-analyze_instance-user-note-following.js new file mode 100644 index 0000000000..f03a60980b --- /dev/null +++ b/packages/backend/migration/1748191631151-analyze_instance-user-note-following.js @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class AnalyzeInstanceUserNoteFollowing1748191631151 { + name = 'AnalyzeInstanceUserNoteFollowing1748191631151' + + async up(queryRunner) { + // Refresh statistics for tables impacted by new indexes. + // This helps the query planner to efficiently use them without waiting for the next full vacuum. + await queryRunner.query(`ANALYZE "instance", "user", "following", "note"`); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/migration/1748990452958-replace_note-userHost_index.js b/packages/backend/migration/1748990452958-replace_note-userHost_index.js new file mode 100644 index 0000000000..55aadd8136 --- /dev/null +++ b/packages/backend/migration/1748990452958-replace_note-userHost_index.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ReplaceNoteUserHostIndex1748990452958 { + name = 'ReplaceNoteUserHostIndex1748990452958' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_7125a826ab192eb27e11d358a5"`); + await queryRunner.query(` + create index "IDX_note_userHost_id" + on "note" ("userHost", "id" desc) + nulls not distinct`); + await queryRunner.query(`comment on index "IDX_note_userHost_id" is 'User host with ID included'`); + } + + async down(queryRunner) { + await queryRunner.query(`drop index if exists "IDX_note_userHost_id"`); + await queryRunner.query(`CREATE INDEX "IDX_7125a826ab192eb27e11d358a5" ON "note" ("userHost") `); + } +} diff --git a/packages/backend/migration/1748990662839-fix-IDX_instance_host_key.js b/packages/backend/migration/1748990662839-fix-IDX_instance_host_key.js new file mode 100644 index 0000000000..fc6d303743 --- /dev/null +++ b/packages/backend/migration/1748990662839-fix-IDX_instance_host_key.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixIDXInstanceHostKey1748990662839 { + async up(queryRunner) { + // must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html + await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`); + await queryRunner.query(` + create index "IDX_instance_host_key" + on "instance" ((lower(reverse("host"::text)) || '.'::text) text_pattern_ops) + include ("host") + `); + await queryRunner.query(`comment on index "IDX_instance_host_key" is 'Expression index for finding instances by base domain'`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`); + } +} diff --git a/packages/backend/migration/1748991828473-create-IDX_note_for_timelines.js b/packages/backend/migration/1748991828473-create-IDX_note_for_timelines.js new file mode 100644 index 0000000000..2ea7fe95d2 --- /dev/null +++ b/packages/backend/migration/1748991828473-create-IDX_note_for_timelines.js @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateIDXNoteForTimelines1748991828473 { + async up(queryRunner) { + await queryRunner.query(` + create index "IDX_note_for_timelines" + on "note" ("id" desc, "channelId", "visibility", "userHost") + include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost") + NULLS NOT DISTINCT`); + await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_note_for_timelines"`); + } +} diff --git a/packages/backend/migration/1748992017688-create-IDX_instance_host_filters.js b/packages/backend/migration/1748992017688-create-IDX_instance_host_filters.js new file mode 100644 index 0000000000..76cf16a6de --- /dev/null +++ b/packages/backend/migration/1748992017688-create-IDX_instance_host_filters.js @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateIDXInstanceHostFilters1748992017688 { + async up(queryRunner) { + await queryRunner.query(` + create index "IDX_instance_host_filters" + on "instance" ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")`); + await queryRunner.query(`comment on index "IDX_instance_host_filters" is 'Covering index for host filter queries'`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_instance_host_filters"`); + } +} diff --git a/packages/backend/migration/1748992128683-create-statistics.js b/packages/backend/migration/1748992128683-create-statistics.js new file mode 100644 index 0000000000..5d08868536 --- /dev/null +++ b/packages/backend/migration/1748992128683-create-statistics.js @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateStatistics1748992128683 { + async up(queryRunner) { + await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`); + await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`); + await queryRunner.query(`CREATE STATISTICS "STTS_note_replyId_replyUserId_replyUserHost" (dependencies) ON "replyId", "replyUserId", "replyUserHost" FROM "note"`) + await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost" (dependencies) ON "renoteId", "renoteUserId", "renoteUserHost" FROM "note"`); + await queryRunner.query(`CREATE STATISTICS "STTS_note_userId_userHost" (mcv) ON "userId", "userHost" FROM "note"`); + await queryRunner.query(`CREATE STATISTICS "STTS_note_replyUserId_replyUserHost" (mcv) ON "replyUserId", "replyUserHost" FROM "note"`); + await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteUserId_renoteUserHost" (mcv) ON "renoteUserId", "renoteUserHost" FROM "note"`); + await queryRunner.query(`ANALYZE "note", "instance"`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isBubbled"`); + await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isSilenced"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_replyId_replyUserId_replyUserHost"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_userId_userHost"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_replyUserId_replyUserHost"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_renoteUserId_renoteUserHost"`); + await queryRunner.query(`ANALYZE "note", "instance"`); + } +} diff --git a/packages/backend/migration/1749097536193-fix-IDX_note_for_timeline.js b/packages/backend/migration/1749097536193-fix-IDX_note_for_timeline.js new file mode 100644 index 0000000000..9a651e5871 --- /dev/null +++ b/packages/backend/migration/1749097536193-fix-IDX_note_for_timeline.js @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixIDXNoteForTimeline1749097536193 { + async up(queryRunner) { + await queryRunner.query('drop index "IDX_note_for_timelines"'); + await queryRunner.query(` + create index "IDX_note_for_timelines" + on "note" ("id" desc, "channelId", "visibility", "userHost") + include ("userId", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost", "threadId") + NULLS NOT DISTINCT + `); + await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`); + } + + async down(queryRunner) { + await queryRunner.query('drop index "IDX_note_for_timelines"'); + await queryRunner.query(` + create index "IDX_note_for_timelines" + on "note" ("id" desc, "channelId", "visibility", "userHost") + include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost") + NULLS NOT DISTINCT + `); + await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`); + } +} diff --git a/packages/backend/migration/1749229288946-create-IDX_note_url.js b/packages/backend/migration/1749229288946-create-IDX_note_url.js new file mode 100644 index 0000000000..4b2fc25cf7 --- /dev/null +++ b/packages/backend/migration/1749229288946-create-IDX_note_url.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateIDXNoteUrl1749229288946 { + name = 'CreateIDXNoteUrl1749229288946' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_url" ON "note" ("url") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_note_url"`); + } +} diff --git a/packages/backend/migration/1749267016885-remove-IDX_instance_host_filters.js b/packages/backend/migration/1749267016885-remove-IDX_instance_host_filters.js new file mode 100644 index 0000000000..d0a4e4f91e --- /dev/null +++ b/packages/backend/migration/1749267016885-remove-IDX_instance_host_filters.js @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RemoveIDXInstanceHostFilters1749267016885 { + async up(queryRunner) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_instance_host_filters"`); + } + + async down(queryRunner) { + await queryRunner.query(` + create index "IDX_instance_host_filters" + on "instance" ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")`); + await queryRunner.query(`comment on index "IDX_instance_host_filters" is 'Covering index for host filter queries'`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 6ee762a8d4..5ec6ededba 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -10,6 +10,9 @@ "start": "node ./built/boot/entry.js", "start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js", + "migrate:revert": "pnpm typeorm migration:revert -d ormconfig.js", + "migrate:generate": "pnpm typeorm migration:generate -d ormconfig.js", + "migrate:create": "pnpm typeorm migration:create", "revert": "pnpm typeorm migration:revert -d ormconfig.js", "check:connect": "node ./scripts/check_connect.js", "build": "swc src -d built -D --strip-leading-paths", @@ -37,18 +40,18 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.3.56", - "@swc/core-darwin-x64": "1.3.56", + "@swc/core-darwin-arm64": "1.11.24", + "@swc/core-darwin-x64": "1.11.24", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.3.56", - "@swc/core-linux-arm64-gnu": "1.3.56", - "@swc/core-linux-arm64-musl": "1.3.56", - "@swc/core-linux-x64-gnu": "1.3.56", - "@swc/core-linux-x64-musl": "1.3.56", - "@swc/core-win32-arm64-msvc": "1.3.56", - "@swc/core-win32-ia32-msvc": "1.3.56", - "@swc/core-win32-x64-msvc": "1.3.56", - "bufferutil": "4.0.7", + "@swc/core-linux-arm-gnueabihf": "1.11.24", + "@swc/core-linux-arm64-gnu": "1.11.24", + "@swc/core-linux-arm64-musl": "1.11.24", + "@swc/core-linux-x64-gnu": "1.11.24", + "@swc/core-linux-x64-musl": "1.11.24", + "@swc/core-win32-arm64-msvc": "1.11.24", + "@swc/core-win32-ia32-msvc": "1.11.24", + "@swc/core-win32-x64-msvc": "1.11.24", + "bufferutil": "4.0.9", "slacc-android-arm-eabi": "0.0.10", "slacc-android-arm64": "0.0.10", "slacc-darwin-arm64": "0.0.10", @@ -62,191 +65,176 @@ "slacc-linux-x64-musl": "0.0.10", "slacc-win32-arm64-msvc": "0.0.10", "slacc-win32-x64-msvc": "0.0.10", - "utf-8-validate": "6.0.3" + "utf-8-validate": "6.0.5" }, "dependencies": { - "@aws-sdk/client-s3": "3.620.0", - "@aws-sdk/lib-storage": "3.620.0", - "@bull-board/api": "6.5.0", - "@bull-board/fastify": "6.5.0", - "@bull-board/ui": "6.5.0", + "@aws-sdk/client-s3": "3.797.0", + "@aws-sdk/lib-storage": "3.797.0", "@discordapp/twemoji": "15.1.0", - "@fastify/accepts": "5.0.1", - "@fastify/cookie": "11.0.1", - "@fastify/cors": "10.0.1", - "@fastify/express": "4.0.1", - "@fastify/http-proxy": "10.0.1", - "@fastify/multipart": "9.0.1", - "@fastify/static": "8.0.2", - "@fastify/view": "10.0.1", - "@misskey-dev/sharp-read-bmp": "1.2.0", - "@transfem-org/summaly": "5.2.1", - "@nestjs/common": "10.4.7", - "@nestjs/core": "10.4.7", - "@nestjs/testing": "10.4.7", + "@fastify/accepts": "5.0.2", + "@fastify/cookie": "11.0.2", + "@fastify/cors": "10.1.0", + "@fastify/express": "4.0.2", + "@fastify/http-proxy": "10.0.2", + "@fastify/multipart": "9.0.3", + "@fastify/static": "8.1.1", + "@fastify/view": "10.0.2", + "@misskey-dev/sharp-read-bmp": "1.3.0", + "@misskey-dev/summaly": "npm:@transfem-org/summaly@5.2.2", + "@nestjs/common": "11.1.0", + "@nestjs/core": "11.1.0", + "@nestjs/testing": "11.1.0", "@peertube/http-signature": "1.7.0", - "@sentry/node": "8.38.0", - "@sentry/profiling-node": "8.38.0", - "@simplewebauthn/server": "10.0.1", - "@sinonjs/fake-timers": "11.2.2", + "@sentry/node": "8.55.0", + "@sentry/profiling-node": "8.55.0", + "@simplewebauthn/server": "12.0.0", + "@sinonjs/fake-timers": "11.3.1", "@smithy/node-http-handler": "2.5.0", - "@swc/cli": "0.3.12", - "@swc/core": "1.9.2", - "@transfem-org/sfm-js": "0.24.5", + "mfm-js": "npm:@transfem-org/sfm-js@0.24.6", "@twemoji/parser": "15.1.1", - "@types/psl": "^1.1.3", "accepts": "1.3.8", "ajv": "8.17.1", "archiver": "7.0.1", - "argon2": "^0.40.1", - "async-mutex": "0.5.0", + "argon2": "0.43.0", + "axios": "1.7.4", "bcryptjs": "2.4.3", "blurhash": "2.0.5", - "body-parser": "1.20.3", - "bullmq": "5.26.1", + "bullmq": "5.51.1", "cacheable-lookup": "7.0.0", - "canvas": "^3.1.0", + "canvas": "3.1.0", "cbor": "9.0.2", - "chalk": "5.3.0", + "chalk": "5.4.1", "chalk-template": "1.1.0", - "chokidar": "3.6.0", - "cli-highlight": "2.1.11", + "cheerio": "1.0.0", + "cli-highlight": "npm:@transfem-org/cli-highlight@2.1.12", "color-convert": "2.0.1", "content-disposition": "0.5.4", "date-fns": "2.30.0", "deep-email-validator": "0.1.21", - "fast-xml-parser": "4.4.1", - "fastify": "5.0.0", - "fastify-multer": "^2.0.3", + "dom-serializer": "2.0.0", + "domhandler": "5.0.3", + "domutils": "3.2.2", + "fastify": "5.3.2", "fastify-raw-body": "5.0.0", "feed": "4.2.2", "file-type": "19.6.0", "fluent-ffmpeg": "2.1.3", - "form-data": "4.0.1", + "form-data": "4.0.2", "glob": "11.0.0", - "got": "14.4.4", - "happy-dom": "15.11.4", + "got": "14.4.7", "hpagent": "1.2.0", "htmlescape": "1.1.1", - "http-link-header": "1.1.3", - "ioredis": "5.4.1", + "htmlparser2": "9.1.0", + "ioredis": "5.6.1", "ip-cidr": "4.0.2", "ipaddr.js": "2.2.0", "is-svg": "5.1.0", "js-yaml": "4.1.0", - "jsdom": "24.1.1", "json5": "2.2.3", - "jsonld": "8.3.2", - "jsrsasign": "11.1.0", - "juice": "11.0.0", + "jsonld": "8.3.3", + "juice": "11.0.1", "megalodon": "workspace:*", - "meilisearch": "0.45.0", - "microformats-parser": "2.0.2", + "meilisearch": "0.50.0", "mime-types": "2.1.35", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", - "moment": "^2.30.1", + "moment": "2.30.1", "ms": "3.0.0-canary.1", - "nanoid": "5.0.8", + "nanoid": "5.1.5", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "nodemailer": "6.9.16", - "oauth": "0.10.0", - "oauth2orize": "1.12.0", - "oauth2orize-pkce": "0.1.2", + "nodemailer": "6.10.1", "os-utils": "0.0.14", - "otpauth": "9.3.4", - "parse5": "7.2.1", - "pg": "8.13.1", + "otpauth": "9.4.0", + "pg": "8.15.6", "pkce-challenge": "4.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", - "proxy-addr": "^2.0.7", - "psl": "^1.13.0", + "proxy-addr": "2.0.7", + "psl": "1.15.0", "pug": "3.0.3", "qrcode": "1.5.4", "random-seed": "0.3.0", - "ratelimiter": "3.4.1", "re2": "1.21.4", + "redis-info": "3.1.0", "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", - "rss-parser": "3.13.0", - "rxjs": "7.8.1", - "sanitize-html": "2.13.1", - "secure-json-parse": "2.7.0", - "sharp": "0.33.5", + "sanitize-html": "2.16.0", + "secure-json-parse": "3.0.2", + "sharp": "0.34.1", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", - "stringz": "2.1.0", - "systeminformation": "5.23.5", + "systeminformation": "5.25.11", "tinycolor2": "1.6.0", "tmp": "0.2.3", - "tsc-alias": "1.8.10", + "tsc-alias": "1.8.15", "tsconfig-paths": "4.2.0", - "typeorm": "0.3.20", - "typescript": "5.6.3", - "ulid": "2.3.0", - "uuid": "^9.0.1", + "typeorm": "0.3.22", + "typescript": "5.8.3", + "ulid": "2.4.0", + "uuid": "11.1.0", "vary": "1.1.2", "web-push": "3.6.7", - "ws": "8.18.0", + "ws": "8.18.1", "xev": "3.0.2" }, "devDependencies": { "@jest/globals": "29.7.0", - "@nestjs/platform-express": "10.4.7", - "@simplewebauthn/types": "10.0.0", - "@swc/jest": "0.2.37", + "@nestjs/platform-express": "11.1.0", + "@sentry/vue": "9.14.0", + "@simplewebauthn/types": "12.0.0", + "@swc/cli": "0.7.3", + "@swc/core": "1.11.24", + "@swc/jest": "0.2.38", "@types/accepts": "1.3.7", "@types/archiver": "6.0.3", "@types/bcryptjs": "2.4.6", - "@types/body-parser": "1.19.5", "@types/color-convert": "2.0.4", "@types/content-disposition": "0.5.8", "@types/fluent-ffmpeg": "2.1.27", "@types/htmlescape": "1.1.3", - "@types/http-link-header": "1.0.7", "@types/jest": "29.5.14", "@types/js-yaml": "4.0.9", - "@types/jsdom": "21.1.7", "@types/jsonld": "1.5.15", - "@types/jsrsasign": "10.5.14", + "@types/jsrsasign": "10.5.15", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "22.9.0", - "@types/nodemailer": "6.4.16", + "@types/node": "22.15.2", + "@types/nodemailer": "6.4.17", "@types/oauth": "0.9.6", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", - "@types/pg": "8.11.10", - "@types/proxy-addr": "^2.0.3", + "@types/pg": "8.11.14", + "@types/proxy-addr": "2.0.3", + "@types/psl": "1.1.3", "@types/pug": "2.0.10", "@types/qrcode": "1.5.5", "@types/random-seed": "0.3.5", - "@types/ratelimiter": "3.4.6", + "@types/redis-info": "3.0.3", "@types/rename": "1.0.7", - "@types/sanitize-html": "2.13.0", - "@types/semver": "7.5.8", + "@types/sanitize-html": "2.15.0", + "@types/semver": "7.7.0", "@types/simple-oauth2": "5.0.7", "@types/sinonjs__fake-timers": "8.1.5", + "@types/supertest": "6.0.3", "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", - "@types/uuid": "^9.0.4", "@types/vary": "1.1.3", "@types/web-push": "3.6.4", - "@types/ws": "8.5.13", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", - "aws-sdk-client-mock": "4.0.1", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.31.0", + "@typescript-eslint/parser": "8.31.0", + "aws-sdk-client-mock": "4.1.0", "cross-env": "7.0.3", - "eslint-plugin-import": "2.30.0", - "execa": "8.0.1", + "eslint-plugin-import": "2.31.0", + "execa": "9.5.2", "fkill": "9.0.0", "jest": "29.7.0", "jest-mock": "29.7.0", - "nodemon": "3.1.7", - "pid-port": "1.0.0", - "simple-oauth2": "5.1.0" + "nodemon": "3.1.10", + "pid-port": "1.0.2", + "simple-oauth2": "5.1.0", + "supertest": "7.1.0" } } diff --git a/packages/backend/scripts/check_connect.js b/packages/backend/scripts/check_connect.js index b15ce51ec8..137712660a 100644 --- a/packages/backend/scripts/check_connect.js +++ b/packages/backend/scripts/check_connect.js @@ -9,7 +9,7 @@ import { createPostgresDataSource } from '../built/postgres.js'; const config = loadConfig(); -// createPostgresDataSource handels primaries and replicas automatically. +// createPostgresDataSource handles primaries and replicas automatically. // usually, it only opens connections first use, so we force it using // .initialize() async function connectToPostgres(){ diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 7ca566477d..90dbdaf2a6 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -141,7 +141,7 @@ const $meta: Provider = { for (const key in body.after) { (meta as any)[key] = (body.after as any)[key]; } - meta.proxyAccount = null; // joinなカラムは通常取ってこないので + meta.rootUser = null; // joinなカラムは通常取ってこないので break; } default: @@ -178,15 +178,13 @@ export class GlobalModule implements OnApplicationShutdown { // Wait for all potential DB queries await allSettled(); // And then disconnect from DB - await Promise.all([ - this.db.destroy(), - this.redisClient.disconnect(), - this.redisForPub.disconnect(), - this.redisForSub.disconnect(), - this.redisForTimelines.disconnect(), - this.redisForReactions.disconnect(), - this.redisForRateLimit.disconnect(), - ]); + await this.db.destroy(); + this.redisClient.disconnect(); + this.redisForPub.disconnect(); + this.redisForSub.disconnect(); + this.redisForTimelines.disconnect(); + this.redisForReactions.disconnect(); + this.redisForRateLimit.disconnect(); } async onApplicationShutdown(signal: string): Promise { diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 735a0f4666..afb48e526c 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -9,6 +9,7 @@ import cluster from 'node:cluster'; import { EventEmitter } from 'node:events'; +import { inspect } from 'node:util'; import chalk from 'chalk'; import Xev from 'xev'; import Logger from '@/logger.js'; @@ -53,20 +54,45 @@ async function main() { // Display detail of unhandled promise rejection if (!envOption.quiet) { - process.on('unhandledRejection', console.dir); + process.on('unhandledRejection', e => { + try { + logger.error('Unhandled rejection:', inspect(e)); + } catch { + console.error('Unhandled rejection:', inspect(e)); + } + }); } // Display detail of uncaught exception - process.on('uncaughtException', err => { + process.on('uncaughtExceptionMonitor', ((err, origin) => { try { - logger.error(err); - console.trace(err); - } catch { } - }); + logger.error(`Uncaught exception (${origin}):`, err); + } catch { + console.error(`Uncaught exception (${origin}):`, err); + } + })); // Dying away... + process.on('disconnect', () => { + try { + logger.warn('IPC channel disconnected! The process may soon die.'); + } catch { + console.warn('IPC channel disconnected! The process may soon die.'); + } + }); + process.on('beforeExit', code => { + try { + logger.warn(`Event loop died! Process will exit with code ${code}.`); + } catch { + console.warn(`Event loop died! Process will exit with code ${code}.`); + } + }); process.on('exit', code => { - logger.info(`The process is going to exit with code ${code}`); + try { + logger.info(`The process is going to exit with code ${code}`); + } catch { + console.info(`The process is going to exit with code ${code}`); + } }); //#endregion diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index cf9e9a9bae..a90228eabc 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import * as os from 'node:os'; import cluster from 'node:cluster'; +import * as net from 'node:net'; import chalk from 'chalk'; import chalkTemplate from 'chalk-template'; import * as Sentry from '@sentry/node'; @@ -18,7 +19,6 @@ import type { Config } from '@/config.js'; import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; import { jobQueue, server } from './common.js'; -import * as net from 'node:net'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -74,7 +74,7 @@ export async function masterMain() { process.exit(1); } - bootLogger.succ('Sharkey initialized'); + bootLogger.info('Sharkey initialized'); if (config.sentryForBackend) { Sentry.init({ @@ -140,10 +140,10 @@ export async function masterMain() { } if (envOption.onlyQueue) { - bootLogger.succ('Queue started', null, true); + bootLogger.info('Queue started', null, true); } else { const addressString = net.isIPv6(config.address) ? `[${config.address}]` : config.address; - bootLogger.succ(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on ${addressString}:${config.port} on ${config.url}`, null, true); + bootLogger.info(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on ${addressString}:${config.port} on ${config.url}`, null, true); } } @@ -172,7 +172,7 @@ function loadConfigBoot(): Config { config = loadConfig(); } catch (exception) { if (typeof exception === 'string') { - configLogger.error(exception); + configLogger.error('Exception loading config:', exception); process.exit(1); } else if ((exception as any).code === 'ENOENT') { configLogger.error('Configuration file not found', null, true); @@ -181,7 +181,7 @@ function loadConfigBoot(): Config { throw exception; } - configLogger.succ('Loaded'); + configLogger.info('Loaded'); return config; } @@ -195,7 +195,7 @@ async function connectDb(): Promise { dbLogger.info('Connecting...'); await initDb(); const v = await db.query('SHOW server_version').then(x => x[0].server_version); - dbLogger.succ(`Connected: v${v}`); + dbLogger.info(`Connected: v${v}`); } catch (err) { dbLogger.error('Cannot connect', null, true); dbLogger.error(err); @@ -211,7 +211,7 @@ async function spawnWorkers(limit = 1) { bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`); await Promise.all([...Array(workers)].map(spawnWorker)); - bootLogger.succ('All workers started'); + bootLogger.info('All workers started'); } function spawnWorker(): Promise { diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c571c227a1..c2e7efd456 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -8,8 +8,12 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; import { globSync } from 'glob'; -import * as Sentry from '@sentry/node'; +import ipaddr from 'ipaddr.js'; +import Logger from './logger.js'; +import type * as Sentry from '@sentry/node'; +import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; +import type { IPv4, IPv6 } from 'ipaddr.js'; type RedisOptionsSource = Partial & { host?: string; @@ -37,6 +41,7 @@ type Source = { db?: string; user?: string; pass?: string; + slowQueryThreshold?: number; disableCache?: boolean; extra?: { [x: string]: string }; }; @@ -66,7 +71,12 @@ type Source = { scope?: 'local' | 'global' | string[]; }; sentryForBackend?: { options: Partial; enableNodeProfiling: boolean; }; - sentryForFrontend?: { options: Partial }; + sentryForFrontend?: { + options: Partial & { dsn: string }; + vueIntegration?: SentryVue.VueIntegrationOptions | null; + browserTracingIntegration?: Parameters[0] | null; + replayIntegration?: Parameters[0] | null; + }; publishTarballInsteadOfProvideRepositoryUrl?: boolean; @@ -76,7 +86,8 @@ type Source = { proxySmtp?: string; proxyBypassHosts?: string[]; - allowedPrivateNetworks?: string[]; + allowedPrivateNetworks?: PrivateNetworkSource[]; + disallowExternalApRedirect?: boolean; maxFileSize?: number; maxNoteLength?: number; @@ -102,6 +113,7 @@ type Source = { deliverJobMaxAttempts?: number; inboxJobMaxAttempts?: number; + mediaDirectory?: string; mediaProxy?: string; proxyRemoteFiles?: boolean; videoThumbnailGenerator?: string; @@ -126,9 +138,10 @@ type Source = { logging?: { sql?: { - disableQueryTruncation? : boolean, - enableQueryParamLogging? : boolean, - } + disableQueryTruncation?: boolean, + enableQueryParamLogging?: boolean, + }; + verbose?: boolean; } activityLogging?: { @@ -136,8 +149,70 @@ type Source = { preSave?: boolean; maxAge?: number; }; + + websocketCompression?: boolean; + + customHtml?: { + head?: string; + } }; +const configLogger = new Logger('config'); + +export type PrivateNetworkSource = string | { network?: string, ports?: number[] }; + +export type PrivateNetwork = { + /** + * CIDR IP/netmask definition of the IP range to match. + */ + cidr: CIDR; + + /** + * List of ports to match. + * If undefined, then all ports match. + * If empty, then NO ports match. + */ + ports?: number[]; +}; + +export type CIDR = [ip: IPv4 | IPv6, prefixLength: number]; + +export function parsePrivateNetworks(patterns: PrivateNetworkSource[]): PrivateNetwork[]; +export function parsePrivateNetworks(patterns: undefined): undefined; +export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefined): PrivateNetwork[] | undefined; +export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefined): PrivateNetwork[] | undefined { + if (!patterns) return undefined; + return patterns + .map(e => { + if (typeof(e) === 'string') { + const cidr = parseIpOrMask(e); + if (cidr) { + return { cidr } satisfies PrivateNetwork; + } + } else if (e.network) { + const cidr = parseIpOrMask(e.network); + if (cidr) { + return { cidr, ports: e.ports } satisfies PrivateNetwork; + } + } + + configLogger.warn('Skipping invalid entry in allowedPrivateNetworks: ', e); + return null; + }) + .filter(p => p != null); +} + +function parseIpOrMask(ipOrMask: string): CIDR | null { + if (ipaddr.isValidCIDR(ipOrMask)) { + return ipaddr.parseCIDR(ipOrMask); + } + if (ipaddr.isValid(ipOrMask)) { + const ip = ipaddr.parse(ipOrMask); + return [ip, 32]; + } + return null; +} + export type Config = { url: string; port: number; @@ -151,6 +226,7 @@ export type Config = { db: string; user: string; pass: string; + slowQueryThreshold?: number; disableCache?: boolean; extra?: { [x: string]: string }; }; @@ -176,7 +252,8 @@ export type Config = { proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; - allowedPrivateNetworks: string[] | undefined; + allowedPrivateNetworks: PrivateNetwork[] | undefined; + disallowExternalApRedirect: boolean; maxFileSize: number; maxNoteLength: number; maxRemoteNoteLength: number; @@ -200,12 +277,14 @@ export type Config = { customMOTD: string[] | undefined; signToActivityPubGet: boolean; attachLdSignatureForRelays: boolean; + /** @deprecated Use MiMeta.allowUnsignedFetch instead */ checkActivityPubGetSignature: boolean | undefined; logging?: { sql?: { - disableQueryTruncation? : boolean, - enableQueryParamLogging? : boolean, - } + disableQueryTruncation?: boolean, + enableQueryParamLogging?: boolean, + }; + verbose?: boolean; } version: string; @@ -224,6 +303,7 @@ export type Config = { frontendManifestExists: boolean; frontendEmbedEntry: string; frontendEmbedManifestExists: boolean; + mediaDirectory: string; mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; @@ -234,7 +314,12 @@ export type Config = { redisForReactions: RedisOptions & RedisOptionsSource; redisForRateLimit: RedisOptions & RedisOptionsSource; sentryForBackend: { options: Partial; enableNodeProfiling: boolean; } | undefined; - sentryForFrontend: { options: Partial } | undefined; + sentryForFrontend: { + options: Partial & { dsn: string }; + vueIntegration?: SentryVue.VueIntegrationOptions | null; + browserTracingIntegration?: Parameters[0] | null; + replayIntegration?: Parameters[0] | null; + } | undefined; perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; @@ -252,6 +337,12 @@ export type Config = { preSave: boolean; maxAge: number; }; + + websocketCompression?: boolean; + + customHtml: { + head: string; + } }; export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch' | 'sqlTsvector'; @@ -262,7 +353,7 @@ const _dirname = dirname(_filename); /** * Path of configuration directory */ -const dir = `${_dirname}/../../../.config`; +const dir = process.env.MISSKEY_CONFIG_DIR ?? `${_dirname}/../../../.config`; /** * Path of configuration file @@ -289,11 +380,14 @@ export function loadConfig(): Config { if (configFiles.length === 0 && !process.env['MK_WARNED_ABOUT_CONFIG']) { - console.log('No config files loaded, check if this is intentional'); + configLogger.warn('No config files loaded, check if this is intentional'); process.env['MK_WARNED_ABOUT_CONFIG'] = '1'; } - const config = configFiles.map(path => fs.readFileSync(path, 'utf-8')) + const config = configFiles.map(path => { + configLogger.info(`Reading configuration from ${path}`); + return fs.readFileSync(path, 'utf-8'); + }) .map(contents => yaml.load(contents) as Source) .reduce( (acc: Source, cur: Source) => Object.assign(acc, cur), @@ -319,6 +413,10 @@ export function loadConfig(): Config { const internalMediaProxy = `${scheme}://${host}/proxy`; const redis = convertRedisOptions(config.redis, host); + // nullish => 300 (default) + // 0 => undefined (disabled) + const slowQueryThreshold = (config.db.slowQueryThreshold ?? 300) || undefined; + return { version, publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl, @@ -337,7 +435,7 @@ export function loadConfig(): Config { apiUrl: `${scheme}://${host}/api`, authUrl: `${scheme}://${host}/auth`, driveUrl: `${scheme}://${host}/files`, - db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass }, + db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass, slowQueryThreshold }, dbReplications: config.dbReplications, dbSlaves: config.dbSlaves, fulltextSearch: config.fulltextSearch, @@ -354,7 +452,8 @@ export function loadConfig(): Config { proxy: config.proxy, proxySmtp: config.proxySmtp, proxyBypassHosts: config.proxyBypassHosts, - allowedPrivateNetworks: config.allowedPrivateNetworks, + allowedPrivateNetworks: parsePrivateNetworks(config.allowedPrivateNetworks), + disallowExternalApRedirect: config.disallowExternalApRedirect ?? false, maxFileSize: config.maxFileSize ?? 262144000, maxNoteLength: config.maxNoteLength ?? 3000, maxRemoteNoteLength: config.maxRemoteNoteLength ?? 100000, @@ -378,6 +477,7 @@ export function loadConfig(): Config { signToActivityPubGet: config.signToActivityPubGet ?? true, attachLdSignatureForRelays: config.attachLdSignatureForRelays ?? true, checkActivityPubGetSignature: config.checkActivityPubGetSignature, + mediaDirectory: config.mediaDirectory ?? resolve(_dirname, '../../../files'), mediaProxy: externalMediaProxy ?? internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, videoThumbnailGenerator: config.videoThumbnailGenerator ? @@ -400,10 +500,18 @@ export function loadConfig(): Config { preSave: config.activityLogging?.preSave ?? false, maxAge: config.activityLogging?.maxAge ?? (1000 * 60 * 60 * 24 * 30), }, + websocketCompression: config.websocketCompression ?? false, + customHtml: { + head: config.customHtml?.head ?? '', + }, }; } function tryCreateUrl(url: string) { + if (!url) { + throw new Error('Failed to load: no "url" property found in config. Please check the value of "MISSKEY_CONFIG_DIR" and "MISSKEY_CONFIG_YML", and verify that all configuration files are correct.'); + } + try { return new URL(url); } catch (e) { @@ -478,7 +586,7 @@ function applyEnvOverrides(config: Source) { } } - function _step2name(step: string|number): string { + function _step2name(step: string | number): string { return step.toString().replaceAll(/[^a-z0-9]+/gi, '').toUpperCase(); } @@ -534,22 +642,27 @@ function applyEnvOverrides(config: Source) { // these are all the settings that can be overridden - _apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications']]); - _apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'disableCache']]); + _apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications', 'websocketCompression']]); + _apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'slowQueryThreshold', 'disableCache']]); _apply_top(['dbSlaves', Array.from((config.dbSlaves ?? []).keys()), ['host', 'port', 'db', 'user', 'pass']]); _apply_top([ ['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines', 'redisForReactions', 'redisForRateLimit'], ['host', 'port', 'username', 'pass', 'db', 'prefix'], ]); _apply_top(['fulltextSearch', 'provider']); - _apply_top(['meilisearch', ['host', 'port', 'apikey', 'ssl', 'index', 'scope']]); + _apply_top(['meilisearch', ['host', 'port', 'apiKey', 'ssl', 'index', 'scope']]); _apply_top([['sentryForFrontend', 'sentryForBackend'], 'options', ['dsn', 'profileSampleRate', 'serverName', 'includeLocalVariables', 'proxy', 'keepAlive', 'caCerts']]); _apply_top(['sentryForBackend', 'enableNodeProfiling']); + _apply_top(['sentryForFrontend', 'vueIntegration', ['attachProps', 'attachErrorHandler']]); + _apply_top(['sentryForFrontend', 'vueIntegration', 'tracingOptions', 'timeout']); + _apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']); _apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]); - _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]); + _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaDirectory', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]); _apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]); _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); - _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword']]); + _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]); _apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]); + _apply_top(['logging', ['verbose']]); _apply_top(['activityLogging', ['enabled', 'preSave', 'maxAge']]); + _apply_top(['customHtml', ['head']]); } diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index e2c492ff80..50ccecd571 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -70,3 +70,9 @@ https://github.com/sindresorhus/file-type/blob/main/supported.js https://github.com/sindresorhus/file-type/blob/main/core.js https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers */ + +export const instanceUnsignedFetchOptions = ['never', 'always', 'essential'] as const; +export type InstanceUnsignedFetchOption = (typeof instanceUnsignedFetchOptions)[number]; + +export const userUnsignedFetchOptions = ['never', 'always', 'essential', 'staff'] as const; +export type UserUnsignedFetchOption = (typeof userUnsignedFetchOptions)[number]; diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index 9bca795479..307f22586e 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -82,6 +82,28 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { } } + /** + * Collects all email addresses that a abuse report should be sent to. + */ + @bindThis + public async getRecipientEMailAddresses(): Promise { + const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it + .filter(it => it.isActive && it.userProfile?.emailVerified) + .map(it => it.userProfile?.email) + .filter(x => x != null), + ); + + if (this.meta.email) { + recipientEMailAddresses.push(this.meta.email); + } + + if (this.meta.maintainerEmail) { + recipientEMailAddresses.push(this.meta.maintainerEmail); + } + + return recipientEMailAddresses; + } + /** * Mailを用いて{@link abuseReports}の内容を管理者各位に通知する. * メールアドレスの送信先は以下の通り. @@ -96,15 +118,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { return; } - const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it - .filter(it => it.isActive && it.userProfile?.emailVerified) - .map(it => it.userProfile?.email) - .filter(x => x != null), - ); - - recipientEMailAddresses.push( - ...(this.meta.email ? [this.meta.email] : []), - ); + const recipientEMailAddresses = await this.getRecipientEMailAddresses(); if (recipientEMailAddresses.length <= 0) { return; diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts index 0b022d3b08..bccb9f86f6 100644 --- a/packages/backend/src/core/AbuseReportService.ts +++ b/packages/backend/src/core/AbuseReportService.ts @@ -10,9 +10,10 @@ import { bindThis } from '@/decorators.js'; import type { AbuseUserReportsRepository, MiAbuseUserReport, MiUser, UsersRepository } from '@/models/_.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { QueueService } from '@/core/QueueService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdService } from './IdService.js'; @Injectable() @@ -27,7 +28,7 @@ export class AbuseReportService { private idService: IdService, private abuseReportNotificationService: AbuseReportNotificationService, private queueService: QueueService, - private instanceActorService: InstanceActorService, + private systemAccountService: SystemAccountService, private apRendererService: ApRendererService, private moderationLogService: ModerationLogService, ) { @@ -125,18 +126,18 @@ export class AbuseReportService { const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId }); if (report.targetUserHost == null) { - throw new Error('The target user host is null.'); + throw new IdentifiableError('0b1ce202-b2c1-4ee4-8af4-2742a51b383d', 'The target user host is null.'); } if (report.forwarded) { - throw new Error('The report has already been forwarded.'); + throw new IdentifiableError('5c008bdf-f0e8-4154-9f34-804e114516d7', 'The report has already been forwarded.'); } await this.abuseUserReportsRepository.update(report.id, { forwarded: true, }); - const actor = await this.instanceActorService.getInstanceActor(); + const actor = await this.systemAccountService.fetch('actor'); const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment); diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index e24fefb4b5..e107f02796 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -20,10 +20,13 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { AntennaService } from '@/core/AntennaService.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class AccountMoveService { @@ -58,12 +61,15 @@ export class AccountMoveService { private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, private globalEventService: GlobalEventService, - private proxyAccountService: ProxyAccountService, private perUserFollowingChart: PerUserFollowingChart, private federatedInstanceService: FederatedInstanceService, private instanceChart: InstanceChart, private relayService: RelayService, private queueService: QueueService, + private systemAccountService: SystemAccountService, + private roleService: RoleService, + private antennaService: AntennaService, + private readonly cacheService: CacheService, ) { } @@ -91,23 +97,22 @@ export class AccountMoveService { const srcPerson = await this.apRendererService.renderPerson(src); const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); await this.apDeliverManagerService.deliverToFollowers(src, updateAct); - this.relayService.deliverToRelays(src, updateAct); + await this.relayService.deliverToRelays(src, updateAct); // Deliver Move activity to the followers of the old account const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst)); await this.apDeliverManagerService.deliverToFollowers(src, moveAct); + await this.relayService.deliverToRelays(src, moveAct); // Publish meUpdated event const iObj = await this.userEntityService.pack(src.id, src, { schema: 'MeDetailed', includeSecrets: true }); this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); // Unfollow after 24 hours - const followings = await this.followingsRepository.findBy({ - followerId: src.id, - }); - this.queueService.createDelayedUnfollowJob(followings.map(following => ({ + const followings = await this.cacheService.userFollowingsCache.fetch(src.id); + this.queueService.createDelayedUnfollowJob(Array.from(followings.keys()).map(followeeId => ({ from: { id: src.id }, - to: { id: following.followeeId }, + to: { id: followeeId }, })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24); await this.postMoveProcess(src, dst); @@ -123,19 +128,19 @@ export class AccountMoveService { this.copyBlocking(src, dst), this.copyMutings(src, dst), this.deleteScheduledNotes(src), + this.copyRoles(src, dst), this.updateLists(src, dst), + this.antennaService.onMoveAccount(src, dst), ]); } catch { /* skip if any error happens */ } // follow the new account - const proxy = await this.proxyAccountService.fetch(); - const followings = await this.followingsRepository.findBy({ - followeeId: src.id, - followerHost: IsNull(), // follower is local - followerId: proxy ? Not(proxy.id) : undefined, - }); + const proxy = await this.systemAccountService.fetch('proxy'); + const followings = await this.cacheService.userFollowersCache.fetch(src.id) + .then(fs => Array.from(fs.values()) + .filter(f => f.followerHost == null && f.followerId !== proxy.id)); const followJobs = followings.map(following => ({ from: { id: following.followerId }, to: { id: dst.id }, @@ -220,6 +225,32 @@ export class AccountMoveService { }); } + @bindThis + public async copyRoles(src: ThinUser, dst: ThinUser): Promise { + // Insert new roles with the same values except userId + // role service may have cache for roles so retrieve roles from service + const [oldRoleAssignments, roles] = await Promise.all([ + this.roleService.getUserAssigns(src.id), + this.roleService.getRoles(), + ]); + + if (oldRoleAssignments.length === 0) return; + + // No promise all since the only async operation is writing to the database + for (const oldRoleAssignment of oldRoleAssignments) { + const role = roles.find(x => x.id === oldRoleAssignment.roleId); + if (role == null) continue; // Very unlikely however removing role may cause this case + if (!role.preserveAssignmentOnMoveAccount) continue; + + try { + await this.roleService.assign(dst.id, role.id, oldRoleAssignment.expiresAt); + } catch (e) { + if (e instanceof RoleService.AlreadyAssignedError) continue; + throw e; + } + } + } + /** * Update lists while moving accounts. * - No removal of the old account from the lists @@ -269,10 +300,8 @@ export class AccountMoveService { // Have the proxy account follow the new account in the same way as UserListService.push if (this.userEntityService.isRemoteUser(dst)) { - const proxy = await this.proxyAccountService.fetch(); - if (proxy) { - this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]); - } + const proxy = await this.systemAccountService.fetch('proxy'); + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]); } } @@ -287,9 +316,9 @@ export class AccountMoveService { await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1); // Decrease follower counts of local followees by 1. - const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id }); - if (oldFollowings.length > 0) { - await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1); + const oldFollowings = await this.cacheService.userFollowingsCache.fetch(oldAccount.id); + if (oldFollowings.size > 0) { + await this.usersRepository.decrement({ id: In(Array.from(oldFollowings.keys())) }, 'followersCount', 1); } // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index 69a57b4854..ef2962fc12 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -27,15 +27,12 @@ export class AccountUpdateService { } @bindThis - public async publishToFollowers(userId: MiUser['id']) { - const user = await this.usersRepository.findOneBy({ id: userId }); - if (user == null) throw new Error('user not found'); - + public async publishToFollowers(user: MiUser) { // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); - this.apDeliverManagerService.deliverToFollowers(user, content); - this.relayService.deliverToRelays(user, content); + await this.apDeliverManagerService.deliverToFollowers(user, content); + await this.relayService.deliverToRelays(user, content); } } } diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index ec9ace417e..667df57943 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -5,18 +5,20 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { In } from 'typeorm'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import * as Acct from '@/misc/acct.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; import type { MiAntenna } from '@/models/Antenna.js'; import type { MiNote } from '@/models/Note.js'; import type { MiUser } from '@/models/User.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import * as Acct from '@/misc/acct.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { CacheService } from './CacheService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -37,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.userListMembershipsRepository) private userListMembershipsRepository: UserListMembershipsRepository, + private cacheService: CacheService, private utilityService: UtilityService, private globalEventService: GlobalEventService, private fanoutTimelineService: FanoutTimelineService, @@ -111,8 +114,7 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise { - if (note.visibility === 'specified') return false; - if (note.visibility === 'followers') return false; + if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false; if (antenna.excludeBots && noteUser.isBot) return false; @@ -120,6 +122,19 @@ export class AntennaService implements OnApplicationShutdown { if (!antenna.withReplies && note.replyId != null) return false; + if (note.visibility === 'specified') { + if (note.userId !== antenna.userId) { + if (note.visibleUserIds == null) return false; + if (!note.visibleUserIds.includes(antenna.userId)) return false; + } + } + + if (note.visibility === 'followers') { + const followings = await this.cacheService.userFollowingsCache.fetch(antenna.userId); + const isFollowing = followings.has(note.userId); + if (!isFollowing && antenna.userId !== note.userId) return false; + } + if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { @@ -210,6 +225,41 @@ export class AntennaService implements OnApplicationShutdown { return this.antennas; } + @bindThis + public async onMoveAccount(src: MiUser, dst: MiUser): Promise { + // There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it. + + // Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list + const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase(); + const antennasToMigrate = (await this.getAntennas()).filter(antenna => { + return antenna.users.some(user => { + const { username, host } = Acct.parse(user); + return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct; + }); + }); + + if (antennasToMigrate.length === 0) return; + + const antennaIds = antennasToMigrate.map(x => x.id); + + // Update the antennas by appending dst users acct to the users list + const dstUserAcct = '@' + Acct.toString({ username: dst.username, host: dst.host }); + + await this.antennasRepository.createQueryBuilder('antenna') + .update() + .set({ + users: () => 'array_append(antenna.users, :dstUserAcct)', + }) + .where('antenna.id IN (:...antennaIds)', { antennaIds }) + .setParameters({ dstUserAcct }) + .execute(); + + // announce update to event + for (const newAntenna of await this.antennasRepository.findBy({ id: In(antennaIds) })) { + this.globalEventService.publishInternalEvent('antennaUpdated', newAntenna); + } + } + @bindThis public dispose(): void { this.redisForSub.off('message', this.onRedisMessage); diff --git a/packages/backend/src/core/ApLogService.ts b/packages/backend/src/core/ApLogService.ts index 096ec21de7..f21c6da313 100644 --- a/packages/backend/src/core/ApLogService.ts +++ b/packages/backend/src/core/ApLogService.ts @@ -139,6 +139,24 @@ export class ApLogService { } } + /** + * Deletes all logged inbox activities from a user or users + * @param userIds IDs of the users to delete + */ + public async deleteInboxLogs(userIds: string | string[]): Promise { + if (Array.isArray(userIds)) { + const logsDeleted = await this.apInboxLogsRepository.delete({ + authUserId: In(userIds), + }); + return logsDeleted.affected ?? 0; + } else { + const logsDeleted = await this.apInboxLogsRepository.delete({ + authUserId: userIds, + }); + return logsDeleted.affected ?? 0; + } + } + /** * Deletes all expired AP logs and garbage-collects the AP context cache. * Returns the total number of deleted rows. diff --git a/packages/backend/src/core/BunnyService.ts b/packages/backend/src/core/BunnyService.ts new file mode 100644 index 0000000000..c9f8a427f5 --- /dev/null +++ b/packages/backend/src/core/BunnyService.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: marie and sharkey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as https from 'node:https'; +import * as fs from 'node:fs'; +import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; +import { Injectable } from '@nestjs/common'; +import type { MiMeta } from '@/models/Meta.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { bindThis } from '@/decorators.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import Logger from '@/logger.js'; + +@Injectable() +export class BunnyService { + private bunnyCdnLogger: Logger; + + constructor( + private httpRequestService: HttpRequestService, + ) { + this.bunnyCdnLogger = new Logger('bunnycdn', 'blue'); + } + + @bindThis + public getBunnyInfo(meta: MiMeta) { + if (!meta.objectStorageEndpoint || !meta.objectStorageBucket || !meta.objectStorageSecretKey) { + throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140bf90', 'Failed to use BunnyCDN, One of the required fields is missing.'); + } + + return { + endpoint: meta.objectStorageEndpoint, + /* + The way S3 works is that the Secret Key is essentially the password for the API but Bunny calls their password AccessKey so we call it accessKey here. + Bunny also doesn't specify a username/s3 access key when doing HTTP API requests so we end up not using our Access Key field from the form. + */ + accessKey: meta.objectStorageSecretKey, + zone: meta.objectStorageBucket, + fullUrl: `https://${meta.objectStorageEndpoint}/${meta.objectStorageBucket}`, + }; + } + + @bindThis + public usingBunnyCDN(meta: MiMeta) { + return meta.objectStorageEndpoint && meta.objectStorageEndpoint.endsWith('bunnycdn.com'); + } + + @bindThis + public async upload(meta: MiMeta, path: string, input: fs.ReadStream | Buffer) { + const client = this.getBunnyInfo(meta); + + // Required to convert the buffer from webpublic and thumbnail to a ReadableStream for PUT + const data = Buffer.isBuffer(input) ? Readable.from(input) : input; + + const agent = this.httpRequestService.getAgentByUrl(new URL(`${client.fullUrl}/${path}`), !meta.objectStorageUseProxy, true); + + // Seperation of path and host/domain is required here + const options = { + method: 'PUT', + host: client.endpoint, + path: `/${client.zone}/${path}`, + headers: { + AccessKey: client.accessKey, + 'Content-Type': 'application/octet-stream', + }, + agent: agent, + }; + + const req = https.request(options); + + // Log and return if BunnyCDN detects wrong data (return is used to prevent console spam as this event occurs multiple times) + req.on('response', (res) => { + if (res.statusCode === 401) { + this.bunnyCdnLogger.error('Invalid AccessKey or region hostname'); + data.destroy(); + return; + } + }); + + req.on('error', (error) => { + this.bunnyCdnLogger.error('Unhandled error', error); + data.destroy(); + throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140bf91', 'An error has occurred while connecting to BunnyCDN', true, error); + }); + + data.pipe(req).on('finish', () => { + data.destroy(); + }); + + // wait till stream gets destroyed upon finish of piping to prevent the UI from showing the upload as success way too early + await finished(data); + } + + @bindThis + public delete(meta: MiMeta, file: string) { + const client = this.getBunnyInfo(meta); + return this.httpRequestService.send(`${client.fullUrl}/${file}`, { method: 'DELETE', headers: { AccessKey: client.accessKey } }); + } +} diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 6725ebe75b..2d37cd6bab 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,27 +5,52 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; +import { In, IsNull } from 'typeorm'; +import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; +import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { InternalEventTypes } from '@/core/GlobalEventService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; +export interface FollowStats { + localFollowing: number; + localFollowers: number; + remoteFollowing: number; + remoteFollowers: number; +} + +export interface CachedTranslation { + sourceLang: string | undefined; + text: string | undefined; +} + +export interface CachedTranslationEntity { + l?: string; + t?: string; + u?: number; +} + @Injectable() export class CacheService implements OnApplicationShutdown { public userByIdCache: MemoryKVCache; public localUserByNativeTokenCache: MemoryKVCache; public localUserByIdCache: MemoryKVCache; public uriPersonCache: MemoryKVCache; - public userProfileCache: RedisKVCache; - public userMutingsCache: RedisKVCache>; - public userBlockingCache: RedisKVCache>; - public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ - public renoteMutingsCache: RedisKVCache>; - public userFollowingsCache: RedisKVCache | undefined>>; + public userProfileCache: QuantumKVCache; + public userMutingsCache: QuantumKVCache>; + public userBlockingCache: QuantumKVCache>; + public userBlockedCache: QuantumKVCache>; // NOTE: 「被」Blockキャッシュ + public renoteMutingsCache: QuantumKVCache>; + public userFollowingsCache: QuantumKVCache>>; + public userFollowersCache: QuantumKVCache>>; + public hibernatedUserCache: QuantumKVCache; + protected userFollowStatsCache = new MemoryKVCache(1000 * 60 * 10); // 10 minutes + protected translationsCache: RedisKVCache; constructor( @Inject(DI.redis) @@ -53,6 +78,7 @@ export class CacheService implements OnApplicationShutdown { private followingsRepository: FollowingsRepository, private userEntityService: UserEntityService, + private readonly internalEventService: InternalEventService, ) { //this.onMessage = this.onMessage.bind(this); @@ -61,76 +87,172 @@ export class CacheService implements OnApplicationShutdown { this.localUserByIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m this.uriPersonCache = new MemoryKVCache(1000 * 60 * 5); // 5m - this.userProfileCache = new RedisKVCache(this.redisClient, 'userProfile', { + this.userProfileCache = new QuantumKVCache(this.internalEventService, 'userProfile', { lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }), - toRedisConverter: (value) => JSON.stringify(value), - fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮 + bulkFetcher: userIds => this.userProfilesRepository.findBy({ userId: In(userIds) }).then(ps => ps.map(p => [p.userId, p])), }); - this.userMutingsCache = new RedisKVCache>(this.redisClient, 'userMutings', { + this.userMutingsCache = new QuantumKVCache>(this.internalEventService, 'userMutings', { lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), + bulkFetcher: muterIds => this.mutingsRepository + .createQueryBuilder('muting') + .select('"muting"."muterId"', 'muterId') + .addSelect('array_agg("muting"."muteeId")', 'muteeIds') + .where({ muterId: In(muterIds) }) + .groupBy('muting.muterId') + .getRawMany<{ muterId: string, muteeIds: string[] }>() + .then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])), }); - this.userBlockingCache = new RedisKVCache>(this.redisClient, 'userBlocking', { + this.userBlockingCache = new QuantumKVCache>(this.internalEventService, 'userBlocking', { lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), + bulkFetcher: blockerIds => this.blockingsRepository + .createQueryBuilder('blocking') + .select('"blocking"."blockerId"', 'blockerId') + .addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds') + .where({ blockerId: In(blockerIds) }) + .groupBy('blocking.blockerId') + .getRawMany<{ blockerId: string, blockeeIds: string[] }>() + .then(ms => ms.map(m => [m.blockerId, new Set(m.blockeeIds)])), }); - this.userBlockedCache = new RedisKVCache>(this.redisClient, 'userBlocked', { + this.userBlockedCache = new QuantumKVCache>(this.internalEventService, 'userBlocked', { lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), + bulkFetcher: blockeeIds => this.blockingsRepository + .createQueryBuilder('blocking') + .select('"blocking"."blockeeId"', 'blockeeId') + .addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds') + .where({ blockeeId: In(blockeeIds) }) + .groupBy('blocking.blockeeId') + .getRawMany<{ blockeeId: string, blockerIds: string[] }>() + .then(ms => ms.map(m => [m.blockeeId, new Set(m.blockerIds)])), }); - this.renoteMutingsCache = new RedisKVCache>(this.redisClient, 'renoteMutings', { + this.renoteMutingsCache = new QuantumKVCache>(this.internalEventService, 'renoteMutings', { lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), + bulkFetcher: muterIds => this.renoteMutingsRepository + .createQueryBuilder('muting') + .select('"muting"."muterId"', 'muterId') + .addSelect('array_agg("muting"."muteeId")', 'muteeIds') + .where({ muterId: In(muterIds) }) + .groupBy('muting.muterId') + .getRawMany<{ muterId: string, muteeIds: string[] }>() + .then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])), }); - this.userFollowingsCache = new RedisKVCache | undefined>>(this.redisClient, 'userFollowings', { + this.userFollowingsCache = new QuantumKVCache>>(this.internalEventService, 'userFollowings', { lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => { - const obj: Record | undefined> = {}; - for (const x of xs) { - obj[x.followeeId] = { withReplies: x.withReplies }; + fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))), + bulkFetcher: followerIds => this.followingsRepository + .findBy({ followerId: In(followerIds) }) + .then(fs => fs + .reduce((groups, f) => { + let group = groups.get(f.followerId); + if (!group) { + group = new Map(); + groups.set(f.followerId, group); + } + group.set(f.followeeId, f); + return groups; + }, new Map>>)), + }); + + this.userFollowersCache = new QuantumKVCache>>(this.internalEventService, 'userFollowers', { + lifetime: 1000 * 60 * 30, // 30m + fetcher: followeeId => this.followingsRepository.findBy({ followeeId: followeeId }).then(xs => new Map(xs.map(x => [x.followerId, x]))), + bulkFetcher: followeeIds => this.followingsRepository + .findBy({ followeeId: In(followeeIds) }) + .then(fs => fs + .reduce((groups, f) => { + let group = groups.get(f.followeeId); + if (!group) { + group = new Map(); + groups.set(f.followeeId, group); + } + group.set(f.followerId, f); + return groups; + }, new Map>>)), + }); + + this.hibernatedUserCache = new QuantumKVCache(this.internalEventService, 'hibernatedUsers', { + lifetime: 1000 * 60 * 30, // 30m + fetcher: async userId => { + const { isHibernated } = await this.usersRepository.findOneOrFail({ + where: { id: userId }, + select: { isHibernated: true }, + }); + return isHibernated; + }, + bulkFetcher: async userIds => { + const results = await this.usersRepository.find({ + where: { id: In(userIds) }, + select: { id: true, isHibernated: true }, + }); + return results.map(({ id, isHibernated }) => [id, isHibernated]); + }, + onChanged: async userIds => { + // We only update local copies since each process will get this event, but we can have user objects in multiple different caches. + // Before doing anything else we must "find" all the objects to update. + const userObjects = new Map(); + const toUpdate: string[] = []; + for (const uid of userIds) { + const toAdd: MiUser[] = []; + + const localUserById = this.localUserByIdCache.get(uid); + if (localUserById) toAdd.push(localUserById); + + const userById = this.userByIdCache.get(uid); + if (userById) toAdd.push(userById); + + if (toAdd.length > 0) { + toUpdate.push(uid); + userObjects.set(uid, toAdd); + } } - return obj; - }), - toRedisConverter: (value) => JSON.stringify(value), - fromRedisConverter: (value) => JSON.parse(value), + + // In many cases, we won't have to do anything. + // Skipping the DB fetch ensures that this remains a single-step synchronous process. + if (toUpdate.length > 0) { + const hibernations = await this.usersRepository.find({ where: { id: In(toUpdate) }, select: { id: true, isHibernated: true } }); + for (const { id, isHibernated } of hibernations) { + const users = userObjects.get(id); + if (users) { + for (const u of users) { + u.isHibernated = isHibernated; + } + } + } + } + }, + }); + + this.translationsCache = new RedisKVCache(this.redisClient, 'translations', { + lifetime: 1000 * 60 * 60 * 24 * 7, // 1 week, + memoryCacheLifetime: 1000 * 60, // 1 minute }); // NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている - this.redisForSub.on('message', this.onMessage); + this.internalEventService.on('userChangeSuspendedState', this.onUserEvent); + this.internalEventService.on('userChangeDeletedState', this.onUserEvent); + this.internalEventService.on('remoteUserUpdated', this.onUserEvent); + this.internalEventService.on('localUserUpdated', this.onUserEvent); + this.internalEventService.on('userChangeSuspendedState', this.onUserEvent); + this.internalEventService.on('userTokenRegenerated', this.onTokenEvent); + this.internalEventService.on('follow', this.onFollowEvent); + this.internalEventService.on('unfollow', this.onFollowEvent); } @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 'userChangeSuspendedState': - case 'userChangeDeletedState': - case 'remoteUserUpdated': - case 'localUserUpdated': { + private async onUserEvent(body: InternalEventTypes[E], _: E, isLocal: boolean): Promise { + { + { + { const user = await this.usersRepository.findOneBy({ id: body.id }); if (user == null) { this.userByIdCache.delete(body.id); @@ -140,6 +262,18 @@ export class CacheService implements OnApplicationShutdown { this.uriPersonCache.delete(k); } } + if (isLocal) { + await Promise.all([ + this.userProfileCache.delete(body.id), + this.userMutingsCache.delete(body.id), + this.userBlockingCache.delete(body.id), + this.userBlockedCache.delete(body.id), + this.renoteMutingsCache.delete(body.id), + this.userFollowingsCache.delete(body.id), + this.userFollowersCache.delete(body.id), + this.hibernatedUserCache.delete(body.id), + ]); + } } else { this.userByIdCache.set(user.id, user); for (const [k, v] of this.uriPersonCache.entries) { @@ -152,24 +286,54 @@ export class CacheService implements OnApplicationShutdown { this.localUserByIdCache.set(user.id, user); } } - break; } - case 'userTokenRegenerated': { + } + } + } + + @bindThis + private async onTokenEvent(body: InternalEventTypes[E]): Promise { + { + { + { const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as MiLocalUser; this.localUserByNativeTokenCache.delete(body.oldToken); this.localUserByNativeTokenCache.set(body.newToken, user); - break; } + } + } + } + + @bindThis + private async onFollowEvent(body: InternalEventTypes[E], type: E): Promise { + { + switch (type) { case 'follow': { const follower = this.userByIdCache.get(body.followerId); if (follower) follower.followingCount++; const followee = this.userByIdCache.get(body.followeeId); if (followee) followee.followersCount++; - this.userFollowingsCache.delete(body.followerId); + await Promise.all([ + this.userFollowingsCache.delete(body.followerId), + this.userFollowersCache.delete(body.followeeId), + ]); + this.userFollowStatsCache.delete(body.followerId); + this.userFollowStatsCache.delete(body.followeeId); break; } - default: + case 'unfollow': { + const follower = this.userByIdCache.get(body.followerId); + if (follower) follower.followingCount--; + const followee = this.userByIdCache.get(body.followeeId); + if (followee) followee.followersCount--; + await Promise.all([ + this.userFollowingsCache.delete(body.followerId), + this.userFollowersCache.delete(body.followeeId), + ]); + this.userFollowStatsCache.delete(body.followerId); + this.userFollowStatsCache.delete(body.followeeId); break; + } } } } @@ -179,9 +343,196 @@ export class CacheService implements OnApplicationShutdown { return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); } + @bindThis + public async findLocalUserById(userId: MiUser['id']): Promise { + return await this.localUserByIdCache.fetchMaybe(userId, async () => { + return await this.usersRepository.findOneBy({ id: userId, host: IsNull() }) as MiLocalUser | null ?? undefined; + }) ?? null; + } + + @bindThis + public async getFollowStats(userId: MiUser['id']): Promise { + return await this.userFollowStatsCache.fetch(userId, async () => { + const stats = { + localFollowing: 0, + localFollowers: 0, + remoteFollowing: 0, + remoteFollowers: 0, + }; + + const followings = await this.followingsRepository.findBy([ + { followerId: userId }, + { followeeId: userId }, + ]); + + for (const following of followings) { + if (following.followerId === userId) { + // increment following; user is a follower of someone else + if (following.followeeHost == null) { + stats.localFollowing++; + } else { + stats.remoteFollowing++; + } + } else if (following.followeeId === userId) { + // increment followers; user is followed by someone else + if (following.followerHost == null) { + stats.localFollowers++; + } else { + stats.remoteFollowers++; + } + } else { + // Should never happen + } + } + + // Infer remote-remote followers heuristically, since we don't track that info directly. + const user = await this.findUserById(userId); + if (user.host !== null) { + stats.remoteFollowing = Math.max(0, user.followingCount - stats.localFollowing); + stats.remoteFollowers = Math.max(0, user.followersCount - stats.localFollowers); + } + + return stats; + }); + } + + @bindThis + public async getCachedTranslation(note: MiNote, targetLang: string): Promise { + const cacheKey = `${note.id}@${targetLang}`; + + // Use cached translation, if present and up-to-date + const cached = await this.translationsCache.get(cacheKey); + if (cached && cached.u === note.updatedAt?.valueOf()) { + return { + sourceLang: cached.l, + text: cached.t, + }; + } + + // No cache entry :( + return null; + } + + @bindThis + public async setCachedTranslation(note: MiNote, targetLang: string, translation: CachedTranslation): Promise { + const cacheKey = `${note.id}@${targetLang}`; + + await this.translationsCache.set(cacheKey, { + l: translation.sourceLang, + t: translation.text, + u: note.updatedAt?.valueOf(), + }); + } + + @bindThis + public async getUsers(userIds: Iterable): Promise> { + const users = new Map; + + const toFetch: string[] = []; + for (const userId of userIds) { + const fromCache = this.userByIdCache.get(userId); + if (fromCache) { + users.set(userId, fromCache); + } else { + toFetch.push(userId); + } + } + + if (toFetch.length > 0) { + const fetched = await this.usersRepository.findBy({ + id: In(toFetch), + }); + + for (const user of fetched) { + users.set(user.id, user); + this.userByIdCache.set(user.id, user); + } + } + + return users; + } + + @bindThis + public async isFollowing(follower: string | { id: string }, followee: string | { id: string }): Promise { + const followerId = typeof(follower) === 'string' ? follower : follower.id; + const followeeId = typeof(followee) === 'string' ? followee : followee.id; + + // This lets us use whichever one is in memory, falling back to DB fetch via userFollowingsCache. + return this.userFollowersCache.get(followeeId)?.has(followerId) + ?? (await this.userFollowingsCache.fetch(followerId)).has(followeeId); + } + + /** + * Returns all hibernated followers. + */ + @bindThis + public async getHibernatedFollowers(followeeId: string): Promise { + const followers = await this.getFollowersWithHibernation(followeeId); + return followers.filter(f => f.isFollowerHibernated); + } + + /** + * Returns all non-hibernated followers. + */ + @bindThis + public async getNonHibernatedFollowers(followeeId: string): Promise { + const followers = await this.getFollowersWithHibernation(followeeId); + return followers.filter(f => !f.isFollowerHibernated); + } + + /** + * Returns follower relations with populated isFollowerHibernated. + * If you don't need this field, then please use userFollowersCache directly for reduced overhead. + */ + @bindThis + public async getFollowersWithHibernation(followeeId: string): Promise { + const followers = await this.userFollowersCache.fetch(followeeId); + const hibernations = await this.hibernatedUserCache.fetchMany(followers.keys()).then(fs => fs.reduce((map, f) => { + map.set(f[0], f[1]); + return map; + }, new Map)); + return Array.from(followers.values()).map(following => ({ + ...following, + isFollowerHibernated: hibernations.get(following.followerId) ?? false, + })); + } + + /** + * Refreshes follower and following relations for the given user. + */ + @bindThis + public async refreshFollowRelationsFor(userId: string): Promise { + const followings = await this.userFollowingsCache.refresh(userId); + const followees = Array.from(followings.values()).map(f => f.followeeId); + await this.userFollowersCache.deleteMany(followees); + } + + @bindThis + public clear(): void { + this.userByIdCache.clear(); + this.localUserByNativeTokenCache.clear(); + this.localUserByIdCache.clear(); + this.uriPersonCache.clear(); + this.userProfileCache.clear(); + this.userMutingsCache.clear(); + this.userBlockingCache.clear(); + this.userBlockedCache.clear(); + this.renoteMutingsCache.clear(); + this.userFollowingsCache.clear(); + this.userFollowStatsCache.clear(); + this.translationsCache.clear(); + } + @bindThis public dispose(): void { - this.redisForSub.off('message', this.onMessage); + this.internalEventService.off('userChangeSuspendedState', this.onUserEvent); + this.internalEventService.off('userChangeDeletedState', this.onUserEvent); + this.internalEventService.off('remoteUserUpdated', this.onUserEvent); + this.internalEventService.off('localUserUpdated', this.onUserEvent); + this.internalEventService.off('userChangeSuspendedState', this.onUserEvent); + this.internalEventService.off('userTokenRegenerated', this.onTokenEvent); + this.internalEventService.off('follow', this.onFollowEvent); + this.internalEventService.off('unfollow', this.onFollowEvent); this.userByIdCache.dispose(); this.localUserByNativeTokenCache.dispose(); this.localUserByIdCache.dispose(); diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index d17101ac97..c526a80aeb 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -47,14 +47,14 @@ export type CaptchaSetting = { siteKey: string | null; secretKey: string | null; } -} +}; export class CaptchaError extends Error { public readonly code: CaptchaErrorCode; public readonly cause?: unknown; constructor(code: CaptchaErrorCode, message: string, cause?: unknown) { - super(message); + super(message, cause ? { cause } : undefined); this.code = code; this.cause = cause; this.name = 'CaptchaError'; @@ -63,11 +63,11 @@ export class CaptchaError extends Error { export type CaptchaSaveSuccess = { success: true; -} +}; export type CaptchaSaveFailure = { success: false; error: CaptchaError; -} +}; export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure; type CaptchaResponse = { @@ -117,7 +117,7 @@ export class CaptchaService { } const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => { - throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`, err); }); if (result.success !== true) { @@ -133,7 +133,7 @@ export class CaptchaService { } const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => { - throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`, err); }); if (result.success !== true) { @@ -209,7 +209,7 @@ export class CaptchaService { } const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { - throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`, err); }); if (result.success !== true) { @@ -375,7 +375,7 @@ export class CaptchaService { throw new CaptchaError(captchaErrorCodes.invalidParameters, 'frc-failed: secret and captureResult are required'); } - await this.verifyFriendlyCaptcha(params.captchaResult, params.captchaResult); + await this.verifyFriendlyCaptcha(params.secret, params.captchaResult); await this.updateMeta(provider, params); }, }[provider]; @@ -386,7 +386,7 @@ export class CaptchaService { this.logger.info(err); const error = err instanceof CaptchaError ? err - : new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`); + : new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`, err); return { success: false, error, diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 12251595e2..430711fef1 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -9,14 +9,15 @@ import { DI } from '@/di-symbols.js'; import type { ChannelFollowingsRepository } from '@/models/_.js'; import { MiChannel } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; -import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { GlobalEvents, GlobalEventService, InternalEventTypes } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; import type { MiLocalUser } from '@/models/User.js'; -import { RedisKVCache } from '@/misc/cache.js'; +import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; +import { InternalEventService } from './InternalEventService.js'; @Injectable() export class ChannelFollowingService implements OnModuleInit { - public userFollowingChannelsCache: RedisKVCache>; + public userFollowingChannelsCache: QuantumKVCache>; constructor( @Inject(DI.redis) @@ -27,19 +28,18 @@ export class ChannelFollowingService implements OnModuleInit { private channelFollowingsRepository: ChannelFollowingsRepository, private idService: IdService, private globalEventService: GlobalEventService, + private readonly internalEventService: InternalEventService, ) { - this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { + this.userFollowingChannelsCache = new QuantumKVCache>(this.internalEventService, 'userFollowingChannels', { lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m fetcher: (key) => this.channelFollowingsRepository.find({ where: { followerId: key }, select: ['followeeId'], }).then(xs => new Set(xs.map(x => x.followeeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), }); - this.redisForSub.on('message', this.onMessage); + this.internalEventService.on('followChannel', this.onMessage); + this.internalEventService.on('unfollowChannel', this.onMessage); } onModuleInit() { @@ -79,18 +79,15 @@ export class ChannelFollowingService implements OnModuleInit { } @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']; + private async onMessage(body: InternalEventTypes[E], type: E): Promise { + { switch (type) { case 'followChannel': { - this.userFollowingChannelsCache.refresh(body.userId); + await this.userFollowingChannelsCache.delete(body.userId); break; } case 'unfollowChannel': { - this.userFollowingChannelsCache.delete(body.userId); + await this.userFollowingChannelsCache.delete(body.userId); break; } } @@ -99,6 +96,8 @@ export class ChannelFollowingService implements OnModuleInit { @bindThis public dispose(): void { + this.internalEventService.off('followChannel', this.onMessage); + this.internalEventService.off('unfollowChannel', this.onMessage); this.userFollowingChannelsCache.dispose(); } diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts new file mode 100644 index 0000000000..9d294a80cb --- /dev/null +++ b/packages/backend/src/core/ChatService.ts @@ -0,0 +1,925 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { bindThis } from '@/decorators.js'; +import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiChatRoomMembership, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { Packed } from '@/misc/json-schema.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { emojiRegex } from '@/misc/emoji-regex.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +const MAX_ROOM_MEMBERS = 30; +const MAX_REACTIONS_PER_MESSAGE = 100; +const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; + +// TODO: ReactionServiceのやつと共通化 +function normalizeEmojiString(x: string) { + const match = emojiRegex.exec(x); + if (match) { + // 合字を含む1つの絵文字 + const unicode = match[0]; + + // 異体字セレクタ除去 + return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); + } else { + throw new Error('invalid emoji'); + } +} + +@Injectable() +export class ChatService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatApprovalsRepository) + private chatApprovalsRepository: ChatApprovalsRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + + @Inject(DI.chatRoomMembershipsRepository) + private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + private chatEntityService: ChatEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private pushNotificationService: PushNotificationService, + private notificationService: NotificationService, + private userBlockingService: UserBlockingService, + private queryService: QueryService, + private roleService: RoleService, + private userFollowingService: UserFollowingService, + private customEmojiService: CustomEmojiService, + private moderationLogService: ModerationLogService, + ) { + } + + @bindThis + public async getChatAvailability(userId: MiUser['id']): Promise<{ read: boolean; write: boolean; }> { + const policies = await this.roleService.getUserPolicies(userId); + + switch (policies.chatAvailability) { + case 'available': + return { + read: true, + write: true, + }; + case 'readonly': + return { + read: true, + write: false, + }; + case 'unavailable': + return { + read: false, + write: false, + }; + default: + throw new Error('invalid chat availability (unreachable)'); + } + } + + /** getChatAvailabilityの糖衣。主にAPI呼び出し時に走らせて、権限的に問題ない場合はそのまま続行する */ + @bindThis + public async checkChatAvailability(userId: MiUser['id'], permission: 'read' | 'write') { + const policy = await this.getChatAvailability(userId); + if (policy[permission] === false) { + throw new Error('ROLE_PERMISSION_DENIED'); + } + } + + @bindThis + public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }): Promise> { + if (fromUser.id === toUser.id) { + throw new Error('yourself'); + } + + const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval') + .where(new Brackets(qb => { // 自分が相手を許可しているか + qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id }) + .andWhere('approval.otherId = :toUserId', { toUserId: toUser.id }); + })) + .orWhere(new Brackets(qb => { // 相手が自分を許可しているか + qb.where('approval.userId = :toUserId', { toUserId: toUser.id }) + .andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id }); + })) + .take(2) + .getMany(); + + const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id); + const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id); + + if (!otherApprovedMe) { + if (toUser.chatScope === 'none') { + throw new Error('recipient is cannot chat (none)'); + } else if (toUser.chatScope === 'followers') { + const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id); + if (!isFollower) { + throw new Error('recipient is cannot chat (followers)'); + } + } else if (toUser.chatScope === 'following') { + const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id); + if (!isFollowing) { + throw new Error('recipient is cannot chat (following)'); + } + } else if (toUser.chatScope === 'mutual') { + const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id); + if (!isMutual) { + throw new Error('recipient is cannot chat (mutual)'); + } + } + } + + if (!(await this.getChatAvailability(toUser.id)).write) { + throw new Error('recipient is cannot chat (policy)'); + } + + const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id); + if (blocked) { + throw new Error('blocked'); + } + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toUserId: toUser.id, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + // 相手を許可しておく + if (!iApprovedOther) { + this.chatApprovalsRepository.insertOne({ + id: this.idService.gen(), + userId: fromUser.id, + otherId: toUser.id, + }); + } + + const packedMessage = await this.chatEntityService.packMessageLiteFor1on1(inserted); + + if (this.userEntityService.isLocalUser(toUser)) { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.set(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`); + redisPipeline.exec(); + } + + if (this.userEntityService.isLocalUser(fromUser)) { + // 自分のストリーム + this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage); + } + + if (this.userEntityService.isLocalUser(toUser)) { + // 相手のストリーム + this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage); + } + + // 3秒経っても既読にならなかったらイベント発行 + if (this.userEntityService.isLocalUser(toUser)) { + setTimeout(async () => { + const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`); + + if (marker == null) return; // 既読 + + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser); + this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); + this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); + }, 3000); + } + + return packedMessage; + } + + @bindThis + public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }): Promise> { + const memberships = (await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id })).map(m => ({ + userId: m.userId, + isMuted: m.isMuted, + })).concat({ // ownerはmembershipレコードを作らないため + userId: toRoom.ownerId, + isMuted: false, + }); + + if (!memberships.some(member => member.userId === fromUser.id)) { + throw new Error('you are not a member of the room'); + } + + const membershipsOtherThanMe = memberships.filter(member => member.userId !== fromUser.id); + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toRoomId: toRoom.id, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted); + + this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage); + + const redisPipeline = this.redisClient.pipeline(); + for (const membership of membershipsOtherThanMe) { + if (membership.isMuted) continue; + + redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`); + } + redisPipeline.exec(); + + // 3秒経っても既読にならなかったらイベント発行 + setTimeout(async () => { + const redisPipeline = this.redisClient.pipeline(); + for (const membership of membershipsOtherThanMe) { + redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`); + } + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + if (markers.every(marker => marker[1] == null)) return; + + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted); + + for (let i = 0; i < membershipsOtherThanMe.length; i++) { + const marker = markers[i][1]; + if (marker == null) continue; + + this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); + this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); + } + }, 3000); + + return packedMessage; + } + + @bindThis + public async readUserChatMessage( + readerId: MiUser['id'], + senderId: MiUser['id'], + ): Promise { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newUserChatMessageExists:${readerId}:${senderId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`); + await redisPipeline.exec(); + } + + @bindThis + public async readRoomChatMessage( + readerId: MiUser['id'], + roomId: MiChatRoom['id'], + ): Promise { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newRoomChatMessageExists:${readerId}:${roomId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `room:${roomId}`); + await redisPipeline.exec(); + } + + @bindThis + public findMessageById(messageId: MiChatMessage['id']) { + return this.chatMessagesRepository.findOneBy({ id: messageId }); + } + + @bindThis + public findMyMessageById(userId: MiUser['id'], messageId: MiChatMessage['id']) { + return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId }); + } + + @bindThis + public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) { + if (await this.isRoomMember(room, meId)) { + return true; + } else { + const iAmModerator = await this.roleService.isModerator({ id: meId }); + if (iAmModerator) { + return true; + } + + return false; + } + } + + @bindThis + public async deleteMessage(message: MiChatMessage) { + await this.chatMessagesRepository.delete(message.id); + + if (message.toUserId) { + const [fromUser, toUser] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: message.fromUserId }), + this.usersRepository.findOneByOrFail({ id: message.toUserId }), + ]); + + if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId, 'deleted', message.id); + if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatUserStream(message.toUserId, message.fromUserId, 'deleted', message.id); + + if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) { + //const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser)); + //this.queueService.deliver(fromUser, activity, toUser.inbox); + } + } else if (message.toRoomId) { + this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id); + } + } + + @bindThis + public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', otherId); + + const messages = await query.take(limit).getMany(); + + return messages; + } + + @bindThis + public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .andWhere('message.toRoomId = :roomId', { roomId }) + .leftJoinAndSelect('message.file', 'file') + .leftJoinAndSelect('message.fromUser', 'fromUser'); + + const messages = await query.take(limit).getMany(); + + return messages; + } + + @bindThis + public async userHistory(meId: MiUser['id'], limit: number): Promise { + const history: MiChatMessage[] = []; + + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: meId }); + + for (let i = 0; i < limit; i++) { + const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!); + + const query = this.chatMessagesRepository.createQueryBuilder('message') + .orderBy('message.id', 'DESC') + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId', { meId: meId }) + .orWhere('message.toUserId = :meId', { meId: meId }); + })) + .andWhere('message.toRoomId IS NULL') + .andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`); + + if (found.length > 0) { + query.andWhere('message.fromUserId NOT IN (:...found)', { found: found }); + query.andWhere('message.toUserId NOT IN (:...found)', { found: found }); + } + + query.setParameters(mutingQuery.getParameters()); + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return history; + } + + @bindThis + public async roomHistory(meId: MiUser['id'], limit: number): Promise { + // TODO: 一回のクエリにまとめられるかも + const [memberRoomIds, ownedRoomIds] = await Promise.all([ + this.chatRoomMembershipsRepository.findBy({ + userId: meId, + }).then(xs => xs.map(x => x.roomId)), + this.chatRoomsRepository.findBy({ + ownerId: meId, + }).then(xs => xs.map(x => x.id)), + ]); + + const roomIds = memberRoomIds.concat(ownedRoomIds); + + if (memberRoomIds.length === 0 && ownedRoomIds.length === 0) { + return []; + } + + const history: MiChatMessage[] = []; + + for (let i = 0; i < limit; i++) { + const found = history.map(m => m.toRoomId!); + + const query = this.chatMessagesRepository.createQueryBuilder('message') + .orderBy('message.id', 'DESC') + .where('message.toRoomId IN (:...roomIds)', { roomIds }); + + if (found.length > 0) { + query.andWhere('message.toRoomId NOT IN (:...found)', { found: found }); + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return history; + } + + @bindThis + public async getUserReadStateMap(userId: MiUser['id'], otherIds: MiUser['id'][]) { + const readStateMap: Record = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const otherId of otherIds) { + redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`); + } + + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + for (let i = 0; i < otherIds.length; i++) { + const marker = markers[i][1]; + readStateMap[otherIds[i]] = marker == null; + } + + return readStateMap; + } + + @bindThis + public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) { + const readStateMap: Record = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const roomId of roomIds) { + redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`); + } + + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + for (let i = 0; i < roomIds.length; i++) { + const marker = markers[i][1]; + readStateMap[roomIds[i]] = marker == null; + } + + return readStateMap; + } + + @bindThis + public async hasUnreadMessages(userId: MiUser['id']) { + const card = await this.redisClient.scard(`newChatMessagesExists:${userId}`); + return card > 0; + } + + @bindThis + public async createRoom(owner: MiUser, params: Partial<{ + name: string; + description: string; + }>) { + const room = { + id: this.idService.gen(), + name: params.name, + description: params.description, + ownerId: owner.id, + } satisfies Partial; + + const created = await this.chatRoomsRepository.insertOne(room); + + return created; + } + + @bindThis + public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) { + if (room.ownerId === meId) { + return true; + } + + const iAmModerator = await this.roleService.isModerator({ id: meId }); + if (iAmModerator) { + return true; + } + + return false; + } + + @bindThis + public async deleteRoom(room: MiChatRoom, deleter?: MiUser) { + await this.chatRoomsRepository.delete(room.id); + + if (deleter) { + const deleterIsModerator = await this.roleService.isModerator(deleter); + + if (deleterIsModerator) { + this.moderationLogService.log(deleter, 'deleteChatRoom', { + roomId: room.id, + room: room, + }); + } + } + } + + @bindThis + public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) { + return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId }); + } + + @bindThis + public async findRoomById(roomId: MiChatRoom['id']) { + return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] }); + } + + @bindThis + public async isRoomMember(room: MiChatRoom, userId: MiUser['id']) { + if (room.ownerId === userId) return true; + const membership = await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId }); + return membership != null; + } + + @bindThis + public async createRoomInvitation(inviterId: MiUser['id'], roomId: MiChatRoom['id'], inviteeId: MiUser['id']) { + if (inviterId === inviteeId) { + throw new Error('yourself'); + } + + const room = await this.chatRoomsRepository.findOneByOrFail({ id: roomId, ownerId: inviterId }); + + if (await this.isRoomMember(room, inviteeId)) { + throw new Error('already member'); + } + + const existingInvitation = await this.chatRoomInvitationsRepository.findOneBy({ roomId, userId: inviteeId }); + if (existingInvitation) { + throw new Error('already invited'); + } + + const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId }); + if (membershipsCount >= MAX_ROOM_MEMBERS) { + throw new Error('room is full'); + } + + // TODO: cehck block + + const invitation = { + id: this.idService.gen(), + roomId: room.id, + userId: inviteeId, + } satisfies Partial; + + const created = await this.chatRoomInvitationsRepository.insertOne(invitation); + + this.notificationService.createNotification(inviteeId, 'chatRoomInvitationReceived', { + invitationId: invitation.id, + }, inviterId); + + return created; + } + + @bindThis + public async getSentRoomInvitationsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId) + .andWhere('invitation.roomId = :roomId', { roomId }); + + const invitations = await query.take(limit).getMany(); + + return invitations; + } + + @bindThis + public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId) + .andWhere('room.ownerId = :ownerId', { ownerId }); + + const rooms = await query.take(limit).getMany(); + + return rooms; + } + + @bindThis + public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId) + .andWhere('invitation.userId = :userId', { userId }) + .andWhere('invitation.ignored = FALSE'); + + const invitations = await query.take(limit).getMany(); + + return invitations; + } + + @bindThis + public async joinToRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId }); + + const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId }); + if (membershipsCount >= MAX_ROOM_MEMBERS) { + throw new Error('room is full'); + } + + const membership = { + id: this.idService.gen(), + roomId: roomId, + userId: userId, + } satisfies Partial; + + // TODO: transaction + await this.chatRoomMembershipsRepository.insertOne(membership); + await this.chatRoomInvitationsRepository.delete(invitation.id); + } + + @bindThis + public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true }); + } + + @bindThis + public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomMembershipsRepository.delete(membership.id); + } + + @bindThis + public async muteRoom(userId: MiUser['id'], roomId: MiChatRoom['id'], mute: boolean) { + const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomMembershipsRepository.update(membership.id, { isMuted: mute }); + } + + @bindThis + public async updateRoom(room: MiChatRoom, params: { + name?: string; + description?: string; + }): Promise { + return this.chatRoomsRepository.createQueryBuilder().update() + .set(params) + .where('id = :id', { id: room.id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); + } + + @bindThis + public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId) + .andWhere('membership.roomId = :roomId', { roomId }); + + const memberships = await query.take(limit).getMany(); + + return memberships; + } + + @bindThis + public async searchMessages(meId: MiUser['id'], query: string, limit: number, params: { + userId?: MiUser['id'] | null; + roomId?: MiChatRoom['id'] | null; + }) { + const q = this.chatMessagesRepository.createQueryBuilder('message'); + + if (params.userId) { + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', params.userId); + } else if (params.roomId) { + q.where('message.toRoomId = :roomId', { roomId: params.roomId }); + } else { + const membershipsQuery = this.chatRoomMembershipsRepository.createQueryBuilder('membership') + .select('membership.roomId') + .where('membership.userId = :meId', { meId: meId }); + + const ownedRoomsQuery = this.chatRoomsRepository.createQueryBuilder('room') + .select('room.id') + .where('room.ownerId = :meId', { meId }); + + q.andWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .orWhere('message.toUserId = :meId') + .orWhere(`message.toRoomId IN (${membershipsQuery.getQuery()})`) + .orWhere(`message.toRoomId IN (${ownedRoomsQuery.getQuery()})`); + })); + + q.setParameters(membershipsQuery.getParameters()); + q.setParameters(ownedRoomsQuery.getParameters()); + } + + q.andWhere('LOWER(message.text) LIKE :q', { q: `%${ sqlLikeEscape(query.toLowerCase()) }%` }); + + q.leftJoinAndSelect('message.file', 'file'); + q.leftJoinAndSelect('message.fromUser', 'fromUser'); + q.leftJoinAndSelect('message.toUser', 'toUser'); + q.leftJoinAndSelect('message.toRoom', 'toRoom'); + q.leftJoinAndSelect('toRoom.owner', 'toRoomOwner'); + + const messages = await q.orderBy('message.id', 'DESC').take(limit).getMany(); + + return messages; + } + + @bindThis + public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) { + let reaction; + + const custom = reaction_.match(isCustomEmojiRegexp); + + if (custom == null) { + reaction = normalizeEmojiString(reaction_); + } else { + const name = custom[1]; + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); + + if (emoji == null) { + throw new Error('no such emoji'); + } else { + reaction = `:${name}:`; + } + } + + const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId }); + + if (message.fromUserId === userId) { + throw new Error('cannot react to own message'); + } + + if (message.toRoomId === null && message.toUserId !== userId) { + throw new Error('cannot react to others message'); + } + + if (message.reactions.length >= MAX_REACTIONS_PER_MESSAGE) { + throw new Error('too many reactions'); + } + + const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null; + + if (room) { + if (!await this.isRoomMember(room, userId)) { + throw new Error('cannot react to others message'); + } + } + + await this.chatMessagesRepository.createQueryBuilder().update() + .set({ + reactions: () => `array_append("reactions", '${userId}/${reaction}')`, + }) + .where('id = :id', { id: message.id }) + .execute(); + + if (room) { + this.globalEventService.publishChatRoomStream(room.id, 'react', { + messageId: message.id, + user: await this.userEntityService.pack(userId), + reaction, + }); + } else { + this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'react', { + messageId: message.id, + reaction, + }); + this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'react', { + messageId: message.id, + reaction, + }); + } + } + + @bindThis + public async unreact(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) { + let reaction; + + const custom = reaction_.match(isCustomEmojiRegexp); + + if (custom == null) { + reaction = normalizeEmojiString(reaction_); + } else { // 削除されたカスタム絵文字のリアクションを削除したいかもしれないので絵文字の存在チェックはする必要なし + const name = custom[1]; + reaction = `:${name}:`; + } + + // NOTE: 自分のリアクションを(あれば)削除するだけなので諸々の権限チェックは必要なし + + const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId }); + + const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null; + + await this.chatMessagesRepository.createQueryBuilder().update() + .set({ + reactions: () => `array_remove("reactions", '${userId}/${reaction}')`, + }) + .where('id = :id', { id: message.id }) + .execute(); + + // TODO: 実際に削除が行われたときのみイベントを発行する + + if (room) { + this.globalEventService.publishChatRoomStream(room.id, 'unreact', { + messageId: message.id, + user: await this.userEntityService.pack(userId), + reaction, + }); + } else { + this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'unreact', { + messageId: message.id, + reaction, + }); + this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'unreact', { + messageId: message.id, + reaction, + }); + } + } + + @bindThis + public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId) + .andWhere('membership.userId = :userId', { userId }); + + const memberships = await query.take(limit).getMany(); + + return memberships; + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 3c35dfc4ff..6839ba0159 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -19,6 +19,7 @@ import { TimeService } from '@/core/TimeService.js'; import { EnvService } from '@/core/EnvService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApLogService } from '@/core/ApLogService.js'; +import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AnnouncementService } from './AnnouncementService.js'; @@ -27,7 +28,6 @@ import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; -import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; import { DownloadService } from './DownloadService.js'; @@ -40,7 +40,8 @@ import { HashtagService } from './HashtagService.js'; import { HttpRequestService } from './HttpRequestService.js'; import { IdService } from './IdService.js'; import { ImageProcessingService } from './ImageProcessingService.js'; -import { InstanceActorService } from './InstanceActorService.js'; +import { SystemAccountService } from './SystemAccountService.js'; +import { InternalEventService } from './InternalEventService.js'; import { InternalStorageService } from './InternalStorageService.js'; import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; @@ -50,7 +51,6 @@ import { NoteEditService } from './NoteEditService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { LatestNoteService } from './LatestNoteService.js'; import { NotePiningService } from './NotePiningService.js'; -import { NoteReadService } from './NoteReadService.js'; import { NotificationService } from './NotificationService.js'; import { PollService } from './PollService.js'; import { PushNotificationService } from './PushNotificationService.js'; @@ -60,6 +60,7 @@ import { ReactionsBufferingService } from './ReactionsBufferingService.js'; import { RelayService } from './RelayService.js'; import { RoleService } from './RoleService.js'; import { S3Service } from './S3Service.js'; +import { BunnyService } from './BunnyService.js'; import { SignupService } from './SignupService.js'; import { WebAuthnService } from './WebAuthnService.js'; import { UserBlockingService } from './UserBlockingService.js'; @@ -74,7 +75,6 @@ import { UserSuspendService } from './UserSuspendService.js'; import { UserAuthService } from './UserAuthService.js'; import { VideoProcessingService } from './VideoProcessingService.js'; import { UserWebhookService } from './UserWebhookService.js'; -import { ProxyAccountService } from './ProxyAccountService.js'; import { UtilityService } from './UtilityService.js'; import { FileInfoService } from './FileInfoService.js'; import { SearchService } from './SearchService.js'; @@ -82,6 +82,7 @@ import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; import { FanoutTimelineService } from './FanoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js'; +import { ChatService } from './ChatService.js'; import { RegistryApiService } from './RegistryApiService.js'; import { ReversiService } from './ReversiService.js'; @@ -107,6 +108,7 @@ import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; import { BlockingEntityService } from './entities/BlockingEntityService.js'; import { ChannelEntityService } from './entities/ChannelEntityService.js'; +import { ChatEntityService } from './entities/ChatEntityService.js'; import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; @@ -173,7 +175,6 @@ const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppL const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; -const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService }; @@ -186,7 +187,7 @@ const $HashtagService: Provider = { provide: 'HashtagService', useExisting: Hash const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService }; const $IdService: Provider = { provide: 'IdService', useExisting: IdService }; const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService }; -const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService }; +const $InternalEventService: Provider = { provide: 'InternalEventService', useExisting: InternalEventService }; const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService }; const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService }; const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; @@ -196,10 +197,9 @@ const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: No const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $LatestNoteService: Provider = { provide: 'LatestNoteService', useExisting: LatestNoteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; -const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; -const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService }; +const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService }; const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService }; const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService }; const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService }; @@ -207,6 +207,7 @@ const $ReactionsBufferingService: Provider = { provide: 'ReactionsBufferingServi const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService }; const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService }; const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; +const $BunnyService: Provider = { provide: 'BunnyService', useExisting: BunnyService }; const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; @@ -220,6 +221,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService', const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; +const $UpdateInstanceQueue: Provider = { provide: 'UpdateInstanceQueue', useExisting: UpdateInstanceQueue }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; @@ -233,6 +235,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; +const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; const $TimeService: Provider = { provide: 'TimeService', useExisting: TimeService }; @@ -261,6 +264,7 @@ const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService }; const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService }; +const $ChatEntityService: Provider = { provide: 'ChatEntityService', useExisting: ChatEntityService }; const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService }; const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; @@ -331,7 +335,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp AchievementService, AvatarDecorationService, CaptchaService, - CreateSystemUserService, CustomEmojiService, DeleteAccountService, DownloadService, @@ -344,7 +347,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp HttpRequestService, IdService, ImageProcessingService, - InstanceActorService, + InternalEventService, InternalStorageService, MetaService, MfmService, @@ -354,10 +357,9 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp NoteDeleteService, LatestNoteService, NotePiningService, - NoteReadService, NotificationService, PollService, - ProxyAccountService, + SystemAccountService, PushNotificationService, QueryService, ReactionService, @@ -365,6 +367,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp RelayService, RoleService, S3Service, + BunnyService, SignupService, WebAuthnService, UserBlockingService, @@ -378,6 +381,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp UserSearchService, UserSuspendService, UserAuthService, + UpdateInstanceQueue, VideoProcessingService, UserWebhookService, SystemWebhookService, @@ -391,6 +395,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, TimeService, @@ -419,6 +424,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -485,7 +491,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $AchievementService, $AvatarDecorationService, $CaptchaService, - $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, $DownloadService, @@ -498,7 +503,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $HttpRequestService, $IdService, $ImageProcessingService, - $InstanceActorService, + $InternalEventService, $InternalStorageService, $MetaService, $MfmService, @@ -508,10 +513,9 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $NoteDeleteService, $LatestNoteService, $NotePiningService, - $NoteReadService, $NotificationService, $PollService, - $ProxyAccountService, + $SystemAccountService, $PushNotificationService, $QueryService, $ReactionService, @@ -519,6 +523,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $RelayService, $RoleService, $S3Service, + $BunnyService, $SignupService, $WebAuthnService, $UserBlockingService, @@ -532,6 +537,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $UserSearchService, $UserSuspendService, $UserAuthService, + $UpdateInstanceQueue, $VideoProcessingService, $UserWebhookService, $SystemWebhookService, @@ -545,6 +551,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, $TimeService, @@ -573,6 +580,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, @@ -640,7 +648,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp AchievementService, AvatarDecorationService, CaptchaService, - CreateSystemUserService, CustomEmojiService, DeleteAccountService, DownloadService, @@ -653,7 +660,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp HttpRequestService, IdService, ImageProcessingService, - InstanceActorService, + InternalEventService, InternalStorageService, MetaService, MfmService, @@ -663,10 +670,9 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp NoteDeleteService, LatestNoteService, NotePiningService, - NoteReadService, NotificationService, PollService, - ProxyAccountService, + SystemAccountService, PushNotificationService, QueryService, ReactionService, @@ -674,6 +680,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp RelayService, RoleService, S3Service, + BunnyService, SignupService, WebAuthnService, UserBlockingService, @@ -687,6 +694,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp UserSearchService, UserSuspendService, UserAuthService, + UpdateInstanceQueue, VideoProcessingService, UserWebhookService, SystemWebhookService, @@ -700,6 +708,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, TimeService, @@ -727,6 +736,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -793,7 +803,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $AchievementService, $AvatarDecorationService, $CaptchaService, - $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, $DownloadService, @@ -806,7 +815,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $HttpRequestService, $IdService, $ImageProcessingService, - $InstanceActorService, + $InternalEventService, $InternalStorageService, $MetaService, $MfmService, @@ -816,10 +825,9 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $NoteDeleteService, $LatestNoteService, $NotePiningService, - $NoteReadService, $NotificationService, $PollService, - $ProxyAccountService, + $SystemAccountService, $PushNotificationService, $QueryService, $ReactionService, @@ -827,6 +835,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $RelayService, $RoleService, $S3Service, + $BunnyService, $SignupService, $WebAuthnService, $UserBlockingService, @@ -840,6 +849,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $UserSearchService, $UserSuspendService, $UserAuthService, + $UpdateInstanceQueue, $VideoProcessingService, $UserWebhookService, $SystemWebhookService, @@ -852,6 +862,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, $TimeService, @@ -879,6 +890,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts deleted file mode 100644 index 14d814b0e6..0000000000 --- a/packages/backend/src/core/CreateSystemUserService.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { randomUUID } from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import * as argon2 from 'argon2'; -//import bcrypt from 'bcryptjs'; -import { IsNull, DataSource } from 'typeorm'; -import { genRsaKeyPair } from '@/misc/gen-key-pair.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 { DI } from '@/di-symbols.js'; -import generateNativeUserToken from '@/misc/generate-native-user-token.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class CreateSystemUserService { - constructor( - @Inject(DI.db) - private db: DataSource, - - private idService: IdService, - ) { - } - - @bindThis - public async createSystemUser(username: string): Promise { - const password = randomUUID(); - - // Generate hash of password - //const salt = await bcrypt.genSalt(8); - const hash = await argon2.hash(password); - - // Generate secret - const secret = generateNativeUserToken(); - - const keyPair = await genRsaKeyPair(); - - let account!: MiUser; - - // 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 user is already exists'); - - account = await transactionalEntityManager.insert(MiUser, { - id: this.idService.gen(), - username: username, - usernameLower: username.toLowerCase(), - host: null, - token: secret, - isRoot: false, - isLocked: true, - isExplorable: false, - approved: true, - isBot: true, - }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); - - await transactionalEntityManager.insert(MiUserKeypair, { - publicKey: keyPair.publicKey, - privateKey: keyPair.privateKey, - userId: account.id, - }); - - await transactionalEntityManager.insert(MiUserProfile, { - userId: account.id, - autoAcceptFollowed: false, - password: hash, - }); - - await transactionalEntityManager.insert(MiUsedUsername, { - createdAt: new Date(), - username: username.toLowerCase(), - }); - }); - - return account; - } -} diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 8408e95863..efbe6a2d59 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Not, IsNull } from 'typeorm'; -import type { FollowingsRepository, MiUser, UsersRepository } from '@/models/_.js'; +import type { FollowingsRepository, MiMeta, MiUser, UsersRepository } from '@/models/_.js'; import { QueueService } from '@/core/QueueService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -13,11 +13,15 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; @Injectable() export class DeleteAccountService { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -29,6 +33,7 @@ export class DeleteAccountService { private queueService: QueueService, private globalEventService: GlobalEventService, private moderationLogService: ModerationLogService, + private systemAccountService: SystemAccountService, ) { } @@ -37,9 +42,13 @@ export class DeleteAccountService { id: string; host: string | null; }, moderator?: MiUser): Promise { + if (this.meta.rootUserId === user.id) throw new Error('cannot delete a root account'); + const _user = await this.usersRepository.findOneByOrFail({ id: user.id }); - if (_user.isRoot) throw new Error('cannot delete a root account'); - if (isSystemAccount(_user)) throw new Error('cannot delete a system account'); + + if (isSystemAccount(_user)) { + throw new Error('cannot delete a system account'); + } if (moderator != null) { this.moderationLogService.log(moderator, 'deleteAccount', { @@ -54,8 +63,6 @@ export class DeleteAccountService { // 知り得る全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()) }, @@ -64,22 +71,17 @@ export class DeleteAccountService { select: ['followerSharedInbox', 'followeeSharedInbox'], }); - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + const inboxes = followings.map(x => [x.followerSharedInbox ?? x.followeeSharedInbox as string, true] as const); + const queue = new Map(inboxes); - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } + await this.queueService.deliverMany(user, content, queue); - for (const inbox of queue) { - this.queueService.deliver(user, content, inbox, true); - } - - this.queueService.createDeleteAccountJob(user, { + await this.queueService.createDeleteAccountJob(user, { soft: false, }); } else { // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する - this.queueService.createDeleteAccountJob(user, { + await this.queueService.createDeleteAccountJob(user, { soft: true, }); } diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 05b9e64a37..cb5bdb6cb7 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -18,6 +18,7 @@ import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; @Injectable() export class DownloadService { @@ -34,10 +35,10 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number} = {} ): Promise<{ + public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number } = {} ): Promise<{ filename: string; }> { - this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); + this.logger.debug(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); const timeout = options.timeout ?? 30 * 1000; const operationTimeout = options.operationTimeout ?? 60 * 1000; @@ -60,8 +61,8 @@ export class DownloadService { request: operationTimeout, // whole operation timeout }, agent: { - http: this.httpRequestService.httpAgent, - https: this.httpRequestService.httpsAgent, + http: this.httpRequestService.getAgentForHttp(urlObj, true), + https: this.httpRequestService.getAgentForHttps(urlObj, true), }, http2: false, // default retry: { @@ -86,7 +87,7 @@ export class DownloadService { filename = parsed.parameters.filename; } } catch (e) { - this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e }); + this.logger.warn(`Failed to parse content-disposition ${contentDisposition}: ${renderInlineError(e)}`); } } }).on('downloadProgress', (progress: Got.Progress) => { @@ -100,13 +101,17 @@ export class DownloadService { await stream.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 new StatusError(`download error from ${url}`, e.response.statusCode, e.response.statusMessage, e); + } else if (e instanceof Got.RequestError || e instanceof Got.AbortError) { + throw new Error(String(e), { cause: e }); + } else if (e instanceof Error) { throw e; + } else { + throw new Error(String(e), { cause: e }); } } - this.logger.succ(`Download finished: ${chalk.cyan(url)}`); + this.logger.info(`Download finished: ${chalk.cyan(url)}`); return { filename, @@ -118,7 +123,7 @@ export class DownloadService { // Create temp file const [path, cleanup] = await createTemp(); - this.logger.info(`text file: Temp file is ${path}`); + this.logger.debug(`text file: Temp file is ${path}`); try { // write content at URL to temp file diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index a65059b417..b9be4e3039 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -44,6 +44,9 @@ import { correctFilename } from '@/misc/correct-filename.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { BunnyService } from '@/core/BunnyService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +import { LoggerService } from './LoggerService.js'; type AddFileArgs = { /** User who wish to add file */ @@ -121,6 +124,7 @@ export class DriveService { private downloadService: DownloadService, private internalStorageService: InternalStorageService, private s3Service: S3Service, + private bunnyService: BunnyService, private imageProcessingService: ImageProcessingService, private videoProcessingService: VideoProcessingService, private globalEventService: GlobalEventService, @@ -131,8 +135,10 @@ export class DriveService { private perUserDriveChart: PerUserDriveChart, private instanceChart: InstanceChart, private utilityService: UtilityService, + + loggerService: LoggerService, ) { - const logger = new Logger('drive', 'blue'); + const logger = loggerService.getLogger('drive', 'blue'); this.registerLogger = logger.createSubLogger('register', 'yellow'); this.downloaderLogger = logger.createSubLogger('downloader'); this.deleteLogger = logger.createSubLogger('delete'); @@ -154,6 +160,14 @@ export class DriveService { // thunbnail, webpublic を必要なら生成 const alts = await this.generateAlts(path, type, !file.uri); + if (type && type.startsWith('video/')) { + try { + await this.videoProcessingService.webOptimizeVideo(path, type); + } catch (err) { + this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`); + } + } + if (this.meta.useObjectStorage) { //#region ObjectStorage params let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']); @@ -177,7 +191,8 @@ export class DriveService { ?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`; // for original - const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`; + const prefix = this.meta.objectStoragePrefix ? `${this.meta.objectStoragePrefix}/` : ''; + const key = `${prefix}${randomUUID()}${ext}`; const url = `${ baseUrl }/${ key }`; // for alts @@ -188,24 +203,24 @@ export class DriveService { //#endregion //#region Uploads - this.registerLogger.info(`uploading original: ${key}`); + this.registerLogger.debug(`uploading original: ${key}`); const uploads = [ this.upload(key, fs.createReadStream(path), type, null, name), ]; if (alts.webpublic) { - webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`; + webpublicKey = `${prefix}webpublic-${randomUUID()}.${alts.webpublic.ext}`; webpublicUrl = `${ baseUrl }/${ webpublicKey }`; - this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); + this.registerLogger.debug(`uploading webpublic: ${webpublicKey}`); uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name)); } if (alts.thumbnail) { - thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; + thumbnailKey = `${prefix}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; - this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); + this.registerLogger.debug(`uploading thumbnail: ${thumbnailKey}`); uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`)); } @@ -249,11 +264,11 @@ export class DriveService { const [url, thumbnailUrl, webpublicUrl] = await Promise.all(promises); if (thumbnailUrl) { - this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`); + this.registerLogger.debug(`thumbnail stored: ${thumbnailAccessKey}`); } if (webpublicUrl) { - this.registerLogger.info(`web stored: ${webpublicAccessKey}`); + this.registerLogger.debug(`web stored: ${webpublicAccessKey}`); } file.storedInternal = true; @@ -297,7 +312,7 @@ export class DriveService { thumbnail, }; } catch (err) { - this.registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`); + this.registerLogger.warn(`GenerateVideoThumbnail failed: ${renderInlineError(err)}`); return { webpublic: null, thumbnail: null, @@ -330,7 +345,7 @@ export class DriveService { metadata.height && metadata.height <= 2048 ); } catch (err) { - this.registerLogger.warn(`sharp failed: ${err}`); + this.registerLogger.warn(`sharp failed: ${renderInlineError(err)}`); return { webpublic: null, thumbnail: null, @@ -341,7 +356,7 @@ export class DriveService { let webpublic: IImage | null = null; if (generateWeb && !satisfyWebpublic && !isAnimated) { - this.registerLogger.info('creating web image'); + this.registerLogger.debug('creating web image'); try { if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { @@ -352,12 +367,12 @@ export class DriveService { this.registerLogger.debug('web image not created (not an required image)'); } } catch (err) { - this.registerLogger.warn('web image not created (an error occurred)', err as Error); + this.registerLogger.warn(`web image not created: ${renderInlineError(err)}`); } } else { - if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)'); - else if (isAnimated) this.registerLogger.info('web image not created (animated image)'); - else this.registerLogger.info('web image not created (from remote)'); + if (satisfyWebpublic) this.registerLogger.debug('web image not created (original satisfies webpublic)'); + else if (isAnimated) this.registerLogger.debug('web image not created (animated image)'); + else this.registerLogger.debug('web image not created (from remote)'); } // #endregion webpublic @@ -371,7 +386,7 @@ export class DriveService { thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422); } } catch (err) { - this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error); + this.registerLogger.warn(`Error creating thumbnail: ${renderInlineError(err)}`); } // #endregion thumbnail @@ -405,20 +420,22 @@ export class DriveService { ); if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - await this.s3Service.upload(this.meta, params) - .then( - result => { - if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput - this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); - } else { // AbortMultipartUploadCommandOutput - this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); - } - }) - .catch( - err => { - this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); - }, - ); + try { + if (this.bunnyService.usingBunnyCDN(this.meta)) { + await this.bunnyService.upload(this.meta, key, stream); + } else { + const result = await this.s3Service.upload(this.meta, params); + if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput + this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); + } else { // AbortMultipartUploadCommandOutput + this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); + throw new Error('S3 upload aborted'); + } + } + } catch (err) { + this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}: ${renderInlineError(err)}`); + throw err; + } } // Expire oldest file (without avatar or banner) of remote user @@ -476,7 +493,6 @@ export class DriveService { }: AddFileArgs): Promise { const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw; const info = await this.fileInfoService.getFileInfo(path); - this.registerLogger.info(`${JSON.stringify(info)}`); // detect name const detectedName = correctFilename( @@ -486,6 +502,8 @@ export class DriveService { ext ?? info.type.ext, ); + this.registerLogger.debug(`Detected file info: ${JSON.stringify(info)}`); + if (user && !force) { // Check if there is a file with the same hash const matched = await this.driveFilesRepository.findOneBy({ @@ -494,7 +512,7 @@ export class DriveService { }); if (matched) { - this.registerLogger.info(`file with same hash is found: ${matched.id}`); + this.registerLogger.debug(`file with same hash is found: ${matched.id}`); if (sensitive && !matched.isSensitive) { // The file is federated as sensitive for this time, but was federated as non-sensitive before. // Therefore, update the file to sensitive. @@ -514,11 +532,23 @@ export class DriveService { const policies = await this.roleService.getUserPolicies(user.id); const driveCapacity = 1024 * 1024 * policies.driveCapacityMb; + const maxFileSize = 1024 * 1024 * policies.maxFileSizeMb; this.registerLogger.debug('drive capacity override applied'); this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); + if (maxFileSize < info.size) { + if (isLocalUser) { + throw new IdentifiableError('f9e4e5f3-4df4-40b5-b400-f236945f7073', 'Max file size exceeded.'); + } else { + // For remote users, throwing an exception will break Activity processing. + // Instead, force "link" mode which does not cache the file locally. + isLink = true; + } + } + // If usage limit exceeded - if (driveCapacity < usage + info.size) { + // Repeat the "!isLink" check because it could be set to true by the previous block. + if (driveCapacity < usage + info.size && !isLink) { if (isLocalUser) { throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.', true); } @@ -610,14 +640,14 @@ export class DriveService { } catch (err) { // duplicate key error (when already registered) if (isDuplicateKeyValueError(err)) { - this.registerLogger.info(`already registered ${file.uri}`); + this.registerLogger.debug(`already registered ${file.uri}`); file = await this.driveFilesRepository.findOneBy({ uri: file.uri!, userId: user ? user.id : IsNull(), }) as MiDriveFile; } else { - this.registerLogger.error(err as Error); + this.registerLogger.error('Error in drive register', err as Error); throw err; } } @@ -625,7 +655,7 @@ export class DriveService { file = await (this.save(file, path, detectedName, info)); } - this.registerLogger.succ(`drive file has been created ${file.id}`); + this.registerLogger.info(`Created file ${file.id} (${detectedName}) of type ${info.type.mime} for user ${user?.id ?? ''}`); if (user) { this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { @@ -814,11 +844,14 @@ export class DriveService { Bucket: this.meta.objectStorageBucket, Key: key, } as DeleteObjectCommandInput; - - await this.s3Service.delete(this.meta, param); + if (this.bunnyService.usingBunnyCDN(this.meta)) { + await this.bunnyService.delete(this.meta, key); + } else { + await this.s3Service.delete(this.meta, param); + } } catch (err: any) { if (err.name === 'NoSuchKey') { - this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error); + this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`); return; } else { throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { @@ -855,13 +888,10 @@ export class DriveService { } const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); - this.downloaderLogger.succ(`Got: ${driveFile.id}`); + this.downloaderLogger.debug(`Upload succeeded: created file ${driveFile.id}`); return driveFile!; } catch (err) { - this.downloaderLogger.error(`Failed to create drive file: ${err}`, { - url: url, - e: err, - }); + this.downloaderLogger.error(`Failed to create drive file from ${url}: ${renderInlineError(err)}`); throw err; } finally { cleanup(); diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index da198d0e42..45d7ea11e4 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -164,6 +164,13 @@ export class EmailService { available: boolean; reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | 'banned' | 'network' | 'blacklist'; }> { + if (!this.utilityService.validateEmailFormat(emailAddress)) { + return { + available: false, + reason: 'format', + }; + } + const exist = await this.userProfilesRepository.countBy({ emailVerified: true, email: emailAddress, diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index bd86a80cbd..f9cf41e854 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -8,10 +8,12 @@ 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 type { MiMeta } from '@/models/Meta.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 { UtilityService } from '@/core/UtilityService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; @@ -30,6 +32,7 @@ type TimelineOptions = { alwaysIncludeMyNotes?: boolean; ignoreAuthorFromBlock?: boolean; ignoreAuthorFromMute?: boolean; + ignoreAuthorFromInstanceBlock?: boolean; excludeNoFiles?: boolean; excludeReplies?: boolean; excludeBots?: boolean; @@ -43,9 +46,13 @@ export class FanoutTimelineEndpointService { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.meta) + private meta: MiMeta, + private noteEntityService: NoteEntityService, private cacheService: CacheService, private fanoutTimelineService: FanoutTimelineService, + private utilityService: UtilityService, ) { } @@ -55,7 +62,7 @@ export class FanoutTimelineEndpointService { } @bindThis - private async getMiNotes(ps: TimelineOptions): Promise { + async getMiNotes(ps: TimelineOptions): Promise { // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); @@ -125,6 +132,19 @@ export class FanoutTimelineEndpointService { }; } + { + const parentFilter = filter; + filter = (note) => { + if (!ps.ignoreAuthorFromInstanceBlock) { + if (note.userInstance?.isBlocked) return false; + } + if (note.userId !== note.renoteUserId && note.renoteUserInstance?.isBlocked) return false; + if (note.userId !== note.replyUserId && note.replyUserInstance?.isBlocked) return false; + + return parentFilter(note); + }; + } + const redisTimeline: MiNote[] = []; let readFromRedis = 0; let lastSuccessfulRate = 1; // rateをキャッシュする? @@ -174,7 +194,10 @@ export class FanoutTimelineEndpointService { .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('note.channel', 'channel') + .leftJoinAndSelect('note.userInstance', 'userInstance') + .leftJoinAndSelect('note.replyUserInstance', 'replyUserInstance') + .leftJoinAndSelect('note.renoteUserInstance', 'renoteUserInstance'); const notes = (await query.getMany()).filter(noteFilter); diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index f6dabfadcd..24999bf4da 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -9,7 +9,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -export type FanoutTimelineName = +export type FanoutTimelineName = ( // home timeline | `homeTimeline:${string}` | `homeTimelineWithFiles:${string}` // only notes with files are included @@ -37,6 +37,7 @@ export type FanoutTimelineName = // role timelines | `roleTimeline:${string}` // any notes are included +); @Injectable() export class FanoutTimelineService { diff --git a/packages/backend/src/core/FeaturedService.ts b/packages/backend/src/core/FeaturedService.ts index b3335e38da..cabbb46504 100644 --- a/packages/backend/src/core/FeaturedService.ts +++ b/packages/backend/src/core/FeaturedService.ts @@ -8,6 +8,7 @@ import * as Redis from 'ioredis'; import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと @@ -21,6 +22,8 @@ export class FeaturedService { constructor( @Inject(DI.redis) private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする + + private readonly roleService: RoleService, ) { } @@ -31,7 +34,14 @@ export class FeaturedService { } @bindThis - private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise { + private async updateRankingOf(name: string, windowRange: number, element: string, score: number, userId: string | null): Promise { + if (userId) { + const policies = await this.roleService.getUserPolicies(userId); + if (!policies.canTrend) { + return; + } + } + const currentWindow = this.getCurrentWindow(windowRange); const redisTransaction = this.redisClient.multi(); redisTransaction.zincrby( @@ -89,28 +99,28 @@ export class FeaturedService { } @bindThis - public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); + public updateGlobalNotesRanking(note: Pick, score = 1): Promise { + return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId); } @bindThis - public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise { - return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score); + public updateGalleryPostsRanking(galleryPost: Pick, score = 1): Promise { + return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPost.id, score, galleryPost.userId); } @bindThis - public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score); + public updateInChannelNotesRanking(channelId: MiNote['channelId'], note: Pick, score = 1): Promise { + return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId); } @bindThis - public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise { - return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score); + public updatePerUserNotesRanking(userId: MiUser['id'], note: Pick, score = 1): Promise { + return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, score, userId); } @bindThis public updateHashtagsRanking(hashtag: string, score = 1): Promise { - return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score); + return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score, null); } @bindThis diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 3f7ed99348..34df10f0ff 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -5,23 +5,24 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; -import { QueryFailedError } from 'typeorm'; -import type { InstancesRepository } from '@/models/_.js'; +import type { InstancesRepository, MiMeta } from '@/models/_.js'; import type { MiInstance } from '@/models/Instance.js'; -import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; +import { MemoryKVCache } 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'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { Serialized } from '@/types.js'; +import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js'; @Injectable() export class FederatedInstanceService implements OnApplicationShutdown { - public federatedInstanceCache: RedisKVCache; + private readonly federatedInstanceCache: MemoryKVCache; constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, @@ -29,67 +30,46 @@ export class FederatedInstanceService implements OnApplicationShutdown { 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, - notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null, - }; - }, - }); + this.federatedInstanceCache = new MemoryKVCache(1000 * 60 * 3); // 3m + this.redisForSub.on('message', this.onMessage); } @bindThis public async fetchOrRegister(host: string): Promise { host = this.utilityService.toPuny(host); - const cached = await this.federatedInstanceCache.get(host); + const cached = this.federatedInstanceCache.get(host); if (cached) return cached; - const index = await this.instancesRepository.findOneBy({ host }); - + let index = await this.instancesRepository.findOneBy({ host }); if (index == null) { - let i; - try { - i = await this.instancesRepository.insertOne({ + await this.instancesRepository.createQueryBuilder('instance') + .insert() + .values({ id: this.idService.gen(), host, firstRetrievedAt: new Date(), - }); - } catch (e: unknown) { - if (e instanceof QueryFailedError) { - if (isDuplicateKeyValueError(e)) { - i = await this.instancesRepository.findOneBy({ host }); - } - } + isBlocked: this.utilityService.isBlockedHost(host), + isSilenced: this.utilityService.isSilencedHost(host), + isMediaSilenced: this.utilityService.isMediaSilencedHost(host), + isAllowListed: this.utilityService.isAllowListedHost(host), + isBubbled: this.utilityService.isBubbledHost(host), + }) + .orIgnore() + .execute(); - if (i == null) { - throw e; - } - } - - this.federatedInstanceCache.set(host, i); - return i; - } else { - this.federatedInstanceCache.set(host, index); - return index; + index = await this.instancesRepository.findOneByOrFail({ host }); } + + this.federatedInstanceCache.set(host, index); + return index; } @bindThis public async fetch(host: string): Promise { host = this.utilityService.toPuny(host); - const cached = await this.federatedInstanceCache.get(host); + const cached = this.federatedInstanceCache.get(host); if (cached !== undefined) return cached; const index = await this.instancesRepository.findOneBy({ host }); @@ -117,8 +97,35 @@ export class FederatedInstanceService implements OnApplicationShutdown { this.federatedInstanceCache.set(result.host, result); } + private syncCache(before: Serialized, after: Serialized): void { + const changed = + diffArraysSimple(before?.blockedHosts, after.blockedHosts) || + diffArraysSimple(before?.silencedHosts, after.silencedHosts) || + diffArraysSimple(before?.mediaSilencedHosts, after.mediaSilencedHosts) || + diffArraysSimple(before?.federationHosts, after.federationHosts) || + diffArraysSimple(before?.bubbleInstances, after.bubbleInstances); + + if (changed) { + // We have to clear the whole thing, otherwise subdomains won't be synced. + this.federatedInstanceCache.clear(); + } + } + + @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']; + if (type === 'metaUpdated') { + this.syncCache(body.before, body.after); + } + } + } + @bindThis public dispose(): void { + this.redisForSub.off('message', this.onMessage); this.federatedInstanceCache.dispose(); } diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index ce3af7c774..6fcfdfb596 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -5,9 +5,9 @@ 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 { load as cheerio } from 'cheerio/slim'; import type { MiInstance } from '@/models/Instance.js'; import type Logger from '@/logger.js'; import { DI } from '@/di-symbols.js'; @@ -15,7 +15,8 @@ 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'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +import type { CheerioAPI } from 'cheerio/slim'; type NodeInfo = { openRegistrations?: unknown; @@ -90,7 +91,7 @@ export class FetchInstanceMetadataService { } } - this.logger.info(`Fetching metadata of ${instance.host} ...`); + this.logger.debug(`Fetching metadata of ${instance.host} ...`); const [info, dom, manifest] = await Promise.all([ this.fetchNodeinfo(instance).catch(() => null), @@ -106,7 +107,7 @@ export class FetchInstanceMetadataService { this.getDescription(info, dom, manifest).catch(() => null), ]); - this.logger.succ(`Successfuly fetched metadata of ${instance.host}`); + this.logger.debug(`Successfuly fetched metadata of ${instance.host}`); const updates = { infoUpdatedAt: new Date(), @@ -128,9 +129,9 @@ export class FetchInstanceMetadataService { await this.federatedInstanceService.update(instance.id, updates); - this.logger.succ(`Successfuly updated metadata of ${instance.host}`); + this.logger.info(`Successfully updated metadata of ${instance.host}`); } catch (e) { - this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); + this.logger.error(`Failed to update metadata of ${instance.host}: ${renderInlineError(e)}`); } finally { await this.unlock(host); } @@ -138,7 +139,7 @@ export class FetchInstanceMetadataService { @bindThis private async fetchNodeinfo(instance: MiInstance): Promise { - this.logger.info(`Fetching nodeinfo of ${instance.host} ...`); + this.logger.debug(`Fetching nodeinfo of ${instance.host} ...`); try { const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') @@ -170,28 +171,25 @@ export class FetchInstanceMetadataService { throw err.statusCode ?? err.message; }); - this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); + this.logger.debug(`Successfuly fetched nodeinfo of ${instance.host}`); return info as NodeInfo; } catch (err) { - this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`); + this.logger.warn(`Failed to fetch nodeinfo of ${instance.host}: ${renderInlineError(err)}`); throw err; } } @bindThis - private async fetchDom(instance: MiInstance): Promise { - this.logger.info(`Fetching HTML of ${instance.host} ...`); + private async fetchDom(instance: MiInstance): Promise { + this.logger.debug(`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; + return cheerio(html); } @bindThis @@ -206,12 +204,15 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise { + private async fetchFaviconUrl(instance: MiInstance, doc: CheerioAPI | 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; + const href = doc('link[rel][href]') + .filter((_, link) => link.attribs.rel.split(' ').includes('icon')) + .last() + .attr('href'); if (href) { return (new URL(href, url)).href; @@ -232,7 +233,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record | null): Promise { + private async fetchIconUrl(instance: MiInstance, doc: CheerioAPI | 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; @@ -242,13 +243,16 @@ export class FetchInstanceMetadataService { const url = 'https://' + instance.host; // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const links = Array.from(doc.getElementsByTagName('link')).reverse(); + const links = Array.from(doc('link[rel][href]')).reverse().map(link => ({ + rel: link.attribs.rel.split(' '), + href: link.attribs.href, + })); // 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, + links.find(link => link.rel.includes('apple-touch-icon-precomposed'))?.href, + links.find(link => link.rel.includes('apple-touch-icon'))?.href, + links.find(link => link.rel.includes('icon'))?.href, ] .find(href => href); @@ -261,8 +265,8 @@ export class FetchInstanceMetadataService { } @bindThis - private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { - const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; + private async getThemeColor(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record | null): Promise { + const themeColor = info?.metadata?.themeColor ?? doc?.('meta[name="theme-color"][content]').attr('content') ?? manifest?.theme_color; if (themeColor) { const color = new tinycolor(themeColor); @@ -273,7 +277,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getSiteName(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeName === 'string') { return info.metadata.nodeName; @@ -283,7 +287,7 @@ export class FetchInstanceMetadataService { } if (doc) { - const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); + const og = doc('meta[property="og:title"][content]').attr('content'); if (og) { return og; @@ -298,7 +302,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getDescription(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeDescription === 'string') { return info.metadata.nodeDescription; @@ -308,12 +312,12 @@ export class FetchInstanceMetadataService { } if (doc) { - const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); + const meta = doc('meta[name="description"][content]').attr('content'); if (meta) { return meta; } - const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); + const og = doc('meta[property="og:description"][content]').attr('content'); if (og) { return og; } diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index cc66e9fe3a..98fbfe5f23 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -46,11 +46,13 @@ const TYPE_SVG = { @Injectable() export class FileInfoService { private logger: Logger; + private ffprobeLogger: Logger; constructor( private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('file-info'); + this.ffprobeLogger = this.logger.createSubLogger('ffprobe'); } /** @@ -162,20 +164,19 @@ export class FileInfoService { */ @bindThis private hasVideoTrackOnVideoFile(path: string): Promise { - const sublogger = this.logger.createSubLogger('ffprobe'); - sublogger.info(`Checking the video file. File path: ${path}`); + this.ffprobeLogger.debug(`Checking the video file. File path: ${path}`); return new Promise((resolve) => { try { FFmpeg.ffprobe(path, (err, metadata) => { if (err) { - sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err); + this.ffprobeLogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err); resolve(true); return; } resolve(metadata.streams.some((stream) => stream.codec_type === 'video')); }); } catch (err) { - sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error); + this.ffprobeLogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error); resolve(true); } }); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 211c22bfaf..c146811331 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; -import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; +import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, 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'; @@ -72,12 +72,8 @@ export interface MainEventTypes { readAllNotifications: undefined; notificationFlushed: undefined; unreadNotification: Packed<'Notification'>; - unreadMention: MiNote['id']; - readAllUnreadMentions: undefined; - unreadSpecifiedNote: MiNote['id']; - readAllUnreadSpecifiedNotes: undefined; - readAllAntennas: undefined; unreadAntenna: MiAntenna; + newChatMessage: Packed<'ChatMessage'>; readAllAnnouncements: undefined; myTokenRegenerated: undefined; signin: { @@ -168,6 +164,21 @@ export interface AdminEventTypes { }; } +export interface ChatEventTypes { + message: Packed<'ChatMessageLite'>; + deleted: Packed<'ChatMessageLite'>['id']; + react: { + reaction: string; + user?: Packed<'UserLite'>; + messageId: MiChatMessage['id']; + }; + unreact: { + reaction: string; + user?: Packed<'UserLite'>; + messageId: MiChatMessage['id']; + }; +} + export interface ReversiEventTypes { matched: { game: Packed<'ReversiGameDetailed'>; @@ -207,7 +218,7 @@ export interface ReversiGameEventTypes { type Events = { [K in keyof T]: { type: K; body: T[K]; } }; type EventUnionFromDictionary< T extends object, - U = Events + U = Events, > = U[keyof U]; type SerializedAll = { @@ -216,7 +227,7 @@ type SerializedAll = { type UndefinedAsNullAll = { [K in keyof T]: T[K] extends undefined ? null : T[K]; -} +}; export interface InternalEventTypes { userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; @@ -254,6 +265,7 @@ export interface InternalEventTypes { unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; + quantumCacheUpdated: { name: string, keys: string[] }; } type EventTypesToEventPayload = EventUnionFromDictionary>>; @@ -300,6 +312,14 @@ export type GlobalEvents = { name: 'notesStream'; payload: Serialized>; }; + chatUser: { + name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`; + payload: EventTypesToEventPayload; + }; + chatRoom: { + name: `chatRoomStream:${MiChatRoom['id']}`; + payload: EventTypesToEventPayload; + }; reversi: { name: `reversiStream:${MiUser['id']}`; payload: EventTypesToEventPayload; @@ -334,12 +354,12 @@ export class GlobalEventService { } @bindThis - private publish(channel: StreamChannels, type: string | null, value?: any): void { + private async publish(channel: StreamChannels, type: string | null, value?: any): Promise { const message = type == null ? value : value == null ? { type: type, body: null } : { type: type, body: value }; - this.redisForPub.publish(this.config.host, JSON.stringify({ + await this.redisForPub.publish(this.config.host, JSON.stringify({ channel: channel, message: message, })); @@ -350,6 +370,11 @@ export class GlobalEventService { this.publish('internal', type, typeof value === 'undefined' ? null : value); } + @bindThis + public async publishInternalEventAsync(type: K, value?: InternalEventTypes[K]): Promise { + await 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); @@ -398,6 +423,16 @@ export class GlobalEventService { this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishChatUserStream(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatUserStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value); + } + + @bindThis + public publishChatRoomStream(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishReversiStream(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void { this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value); diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 19992a7597..151097095d 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -12,20 +12,44 @@ 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 type { Config, PrivateNetwork } from '@/config.js'; import { StatusError } from '@/misc/status-error.js'; import { bindThis } from '@/decorators.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; -import { IObject } from '@/core/activitypub/type.js'; +import type { IObject, IObjectWithId } from '@/core/activitypub/type.js'; import { ApUtilityService } from './activitypub/ApUtilityService.js'; import type { Response } from 'node-fetch'; import type { URL } from 'node:url'; +import type { Socket } from 'node:net'; export type HttpRequestSendOptions = { throwErrorWhenResponseNotOk: boolean; validators?: ((res: Response) => void)[]; }; +export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean { + const parsedIp = ipaddr.parse(ip); + + for (const { cidr, ports } of allowedPrivateNetworks ?? []) { + if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(cidr)) { + if (ports == null || (port != null && ports.includes(port))) { + return false; + } + } + } + + return parsedIp.range() !== 'unicast'; +} + +export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void { + const address = socket.remoteAddress; + if (address && ipaddr.isValid(address)) { + if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) { + socket.destroy(new Error(`Blocked address: ${address}`)); + } + } +} + declare module 'node:http' { interface Agent { createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket; @@ -44,31 +68,12 @@ class HttpRequestServiceAgent extends http.Agent { public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket { const socket = super.createConnection(options, callback) .on('connect', () => { - const address = socket.remoteAddress; if (process.env.NODE_ENV === 'production') { - if (address && ipaddr.isValid(address)) { - if (this.isPrivateIp(address)) { - socket.destroy(new Error(`Blocked address: ${address}`)); - } - } + validateSocketConnect(this.config.allowedPrivateNetworks, socket); } }); return socket; } - - @bindThis - private isPrivateIp(ip: string): boolean { - const parsedIp = ipaddr.parse(ip); - - for (const net of this.config.allowedPrivateNetworks ?? []) { - const cidr = ipaddr.parseCIDR(net); - if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) { - return false; - } - } - - return parsedIp.range() !== 'unicast'; - } } class HttpsRequestServiceAgent extends https.Agent { @@ -83,31 +88,12 @@ class HttpsRequestServiceAgent extends https.Agent { public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket { const socket = super.createConnection(options, callback) .on('connect', () => { - const address = socket.remoteAddress; if (process.env.NODE_ENV === 'production') { - if (address && ipaddr.isValid(address)) { - if (this.isPrivateIp(address)) { - socket.destroy(new Error(`Blocked address: ${address}`)); - } - } + validateSocketConnect(this.config.allowedPrivateNetworks, socket); } }); return socket; } - - @bindThis - private isPrivateIp(ip: string): boolean { - const parsedIp = ipaddr.parse(ip); - - for (const net of this.config.allowedPrivateNetworks ?? []) { - const cidr = ipaddr.parseCIDR(net); - if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) { - return false; - } - } - - return parsedIp.range() !== 'unicast'; - } } @Injectable() @@ -115,32 +101,32 @@ export class HttpRequestService { /** * Get http non-proxy agent (without local address filtering) */ - private httpNative: http.Agent; + private readonly httpNative: http.Agent; /** * Get https non-proxy agent (without local address filtering) */ - private httpsNative: https.Agent; + private readonly httpsNative: https.Agent; /** * Get http non-proxy agent */ - private http: http.Agent; + private readonly http: http.Agent; /** * Get https non-proxy agent */ - private https: https.Agent; + private readonly https: https.Agent; /** * Get http proxy or non-proxy agent */ - public httpAgent: http.Agent; + public readonly httpAgent: http.Agent; /** * Get https proxy or non-proxy agent */ - public httpsAgent: https.Agent; + public readonly httpsAgent: https.Agent; constructor( @Inject(DI.config) @@ -198,7 +184,7 @@ export class HttpRequestService { /** * Get agent by URL * @param url URL - * @param bypassProxy Allways bypass proxy + * @param bypassProxy Always bypass proxy * @param isLocalAddressAllowed */ @bindThis @@ -216,8 +202,42 @@ export class HttpRequestService { } } + /** + * Get agent for http by URL + * @param url URL + * @param isLocalAddressAllowed + */ @bindThis - public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise { + public getAgentForHttp(url: URL, isLocalAddressAllowed = false): http.Agent { + if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) { + return isLocalAddressAllowed + ? this.httpNative + : this.http; + } else { + return this.httpAgent; + } + } + + /** + * Get agent for https by URL + * @param url URL + * @param isLocalAddressAllowed + */ + @bindThis + public getAgentForHttps(url: URL, isLocalAddressAllowed = false): https.Agent { + if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) { + return isLocalAddressAllowed + ? this.httpsNative + : this.https; + } else { + return this.httpsAgent; + } + } + + @bindThis + public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise { + this.apUtilityService.assertApUrl(url); + const res = await this.send(url, { method: 'GET', headers: { @@ -235,9 +255,13 @@ export class HttpRequestService { // Make sure the object ID matches the final URL (which is where it actually exists). // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. - this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + if (allowAnonymous && activity.id == null) { + activity.id = res.url; + } else { + this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + } - return activity; + return activity as IObjectWithId; } @bindThis @@ -307,7 +331,7 @@ export class HttpRequestService { }); if (!res.ok && extra.throwErrorWhenResponseNotOk) { - throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); + throw new StatusError(`request error from ${url}`, res.status, res.statusText); } if (res.ok) { diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 10df6ef266..223a8de678 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -7,13 +7,13 @@ 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 { genAid, isSafeAidT, parseAid, parseAidFull } from '@/misc/id/aid.js'; +import { genAidx, isSafeAidxT, parseAidx, parseAidxFull } from '@/misc/id/aidx.js'; +import { genMeid, isSafeMeidT, parseMeid, parseMeidFull } from '@/misc/id/meid.js'; +import { genMeidg, isSafeMeidgT, parseMeidg, parseMeidgFull } from '@/misc/id/meidg.js'; +import { genObjectId, isSafeObjectIdT, parseObjectId, parseObjectIdFull } from '@/misc/id/object-id.js'; import { bindThis } from '@/decorators.js'; -import { parseUlid } from '@/misc/id/ulid.js'; +import { parseUlid, parseUlidFull } from '@/misc/id/ulid.js'; @Injectable() export class IdService { @@ -70,4 +70,18 @@ export class IdService { default: throw new Error('unrecognized id generation method'); } } + + // Note: additional is at most 64 bits + @bindThis + public parseFull(id: string): { date: number; additional: bigint; } { + switch (this.method) { + case 'aid': return parseAidFull(id); + case 'aidx': return parseAidxFull(id); + case 'objectid': return parseObjectIdFull(id); + case 'meid': return parseMeidFull(id); + case 'meidg': return parseMeidgFull(id); + case 'ulid': return parseUlidFull(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 index 6f978b34c8..6f60475442 100644 --- a/packages/backend/src/core/ImageProcessingService.ts +++ b/packages/backend/src/core/ImageProcessingService.ts @@ -34,6 +34,7 @@ export const webpDefault: sharp.WebpOptions = { smartSubsample: true, mixed: true, effort: 2, + loop: 0, }; export const avifDefault: sharp.AvifOptions = { diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts deleted file mode 100644 index 22c47297a3..0000000000 --- a/packages/backend/src/core/InstanceActorService.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, Not } 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 realLocalUsersPresent(): Promise { - return await this.usersRepository.existsBy({ - host: IsNull(), - username: Not(ACTOR_USERNAME), - }); - } - - @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/InternalEventService.ts b/packages/backend/src/core/InternalEventService.ts new file mode 100644 index 0000000000..5b164b605e --- /dev/null +++ b/packages/backend/src/core/InternalEventService.ts @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { GlobalEvents, InternalEventTypes } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; + +export type Listener = (value: InternalEventTypes[K], key: K, isLocal: boolean) => void | Promise; + +export interface ListenerProps { + ignoreLocal?: boolean, + ignoreRemote?: boolean, +} + +@Injectable() +export class InternalEventService implements OnApplicationShutdown { + private readonly listeners = new Map, ListenerProps>>(); + + constructor( + @Inject(DI.redisForSub) + private readonly redisForSub: Redis.Redis, + + private readonly globalEventService: GlobalEventService, + ) { + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + public on(type: K, listener: Listener, props?: ListenerProps): void { + let set = this.listeners.get(type); + if (!set) { + set = new Map(); + this.listeners.set(type, set); + } + + // Functionally, this is just a set with metadata on the values. + set.set(listener as Listener, props ?? {}); + } + + @bindThis + public off(type: K, listener: Listener): void { + this.listeners.get(type)?.delete(listener as Listener); + } + + @bindThis + public async emit(type: K, value: InternalEventTypes[K]): Promise { + await this.emitInternal(type, value, true); + await this.globalEventService.publishInternalEventAsync(type, { ...value, _pid: process.pid }); + } + + @bindThis + private async emitInternal(type: K, value: InternalEventTypes[K], isLocal: boolean): Promise { + const listeners = this.listeners.get(type); + if (!listeners) { + return; + } + + const promises: Promise[] = []; + for (const [listener, props] of listeners) { + if ((isLocal && !props.ignoreLocal) || (!isLocal && !props.ignoreRemote)) { + const promise = Promise.resolve(listener(value, type, isLocal)); + promises.push(promise); + } + } + await Promise.all(promises); + } + + @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']; + if (!isLocalInternalEvent(body) || body._pid !== process.pid) { + await this.emitInternal(type, body as InternalEventTypes[keyof InternalEventTypes], false); + } + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + this.listeners.clear(); + } + + @bindThis + public onApplicationShutdown(): void { + this.dispose(); + } +} + +interface LocalInternalEvent { + _pid: number; +} + +function isLocalInternalEvent(body: object): body is LocalInternalEvent { + return '_pid' in body && typeof(body._pid) === 'number'; +} diff --git a/packages/backend/src/core/InternalStorageService.ts b/packages/backend/src/core/InternalStorageService.ts index b00c5796d2..abdbbc61d3 100644 --- a/packages/backend/src/core/InternalStorageService.ts +++ b/packages/backend/src/core/InternalStorageService.ts @@ -6,18 +6,11 @@ import * as fs from 'node:fs'; import { copyFile, unlink, writeFile, chmod } from 'node:fs/promises'; 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( @@ -25,12 +18,12 @@ export class InternalStorageService { private config: Config, ) { // No one should erase the working directory *while the server is running*. - fs.mkdirSync(path, { recursive: true }); + fs.mkdirSync(this.config.mediaDirectory, { recursive: true }); } @bindThis public resolvePath(key: string) { - return Path.resolve(path, key); + return Path.resolve(this.config.mediaDirectory, key); } @bindThis diff --git a/packages/backend/src/core/LatestNoteService.ts b/packages/backend/src/core/LatestNoteService.ts index c379805506..63f973c6c6 100644 --- a/packages/backend/src/core/LatestNoteService.ts +++ b/packages/backend/src/core/LatestNoteService.ts @@ -7,6 +7,7 @@ import { DI } from '@/di-symbols.js'; import type { LatestNotesRepository, NotesRepository } from '@/models/_.js'; import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; +import { QueryService } from './QueryService.js'; @Injectable() export class LatestNoteService { @@ -14,11 +15,12 @@ export class LatestNoteService { constructor( @Inject(DI.notesRepository) - private notesRepository: NotesRepository, + private readonly notesRepository: NotesRepository, @Inject(DI.latestNotesRepository) - private latestNotesRepository: LatestNotesRepository, + private readonly latestNotesRepository: LatestNotesRepository, + private readonly queryService: QueryService, loggerService: LoggerService, ) { this.logger = loggerService.getLogger('LatestNoteService'); @@ -91,7 +93,7 @@ export class LatestNoteService { // Find the newest remaining note for the user. // We exclude DMs and pure renotes. - const nextLatest = await this.notesRepository + const query = this.notesRepository .createQueryBuilder('note') .select() .where({ @@ -106,18 +108,11 @@ export class LatestNoteService { ? Not(null) : null, }) - .andWhere(` - ( - note."renoteId" IS NULL - OR note.text IS NOT NULL - OR note.cw IS NOT NULL - OR note."replyId" IS NOT NULL - OR note."hasPoll" - OR note."fileIds" != '{}' - ) - `) - .orderBy({ id: 'DESC' }) - .getOne(); + .orderBy({ id: 'DESC' }); + + this.queryService.andIsNotRenote(query, 'note'); + + const nextLatest = await query.getOne(); if (!nextLatest) return; // Record it as the latest diff --git a/packages/backend/src/core/LoggerService.ts b/packages/backend/src/core/LoggerService.ts index f102461a50..25721f0630 100644 --- a/packages/backend/src/core/LoggerService.ts +++ b/packages/backend/src/core/LoggerService.ts @@ -3,19 +3,25 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import type { KEYWORD } from 'color-convert/conversions.js'; +import { envOption } from '@/env.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; @Injectable() export class LoggerService { constructor( + @Inject(DI.config) + private config: Config, ) { } @bindThis public getLogger(domain: string, color?: KEYWORD | undefined) { - return new Logger(domain, color); + const verbose = this.config.logging?.verbose || envOption.verbose; + return new Logger(domain, color, verbose); } } diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 3d88d0aefe..07f82dc23e 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, EntityManager } from 'typeorm'; import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { MiMeta } from '@/models/Meta.js'; @@ -12,6 +12,9 @@ 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 { MiInstance } from '@/models/Instance.js'; +import { diffArrays } from '@/misc/diff-arrays.js'; +import type { MetasRepository } from '@/models/_.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -26,6 +29,9 @@ export class MetaService implements OnApplicationShutdown { @Inject(DI.db) private db: DataSource, + @Inject(DI.metasRepository) + private readonly metasRepository: MetasRepository, + private featuredService: FeaturedService, private globalEventService: GlobalEventService, ) { @@ -53,7 +59,7 @@ export class MetaService implements OnApplicationShutdown { case 'metaUpdated': { this.cache = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい ...(body.after), - proxyAccount: null, // joinなカラムは通常取ってこないので + rootUser: null, // joinなカラムは通常取ってこないので }; break; } @@ -67,35 +73,35 @@ export class MetaService implements OnApplicationShutdown { 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を優先する + let meta = await this.metasRepository.createQueryBuilder('meta') + .select() + .orderBy({ + id: 'DESC', + }) + .limit(1) + .getOne(); + + if (!meta) { + await this.metasRepository.createQueryBuilder('meta') + .insert() + .values({ + id: 'x', + }) + .orIgnore() + .execute(); + + meta = await this.metasRepository.createQueryBuilder('meta') + .select() + .orderBy({ id: 'DESC', - }, - }); + }) + .limit(1) + .getOneOrFail(); + } - 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; - } - }); + this.cache = meta; + return meta; } @bindThis @@ -103,7 +109,7 @@ export class MetaService implements OnApplicationShutdown { let before: MiMeta | undefined; const updated = await this.db.transaction(async transactionalEntityManager => { - const metas = await transactionalEntityManager.find(MiMeta, { + const metas: (MiMeta | undefined)[] = await transactionalEntityManager.find(MiMeta, { order: { id: 'DESC', }, @@ -113,17 +119,24 @@ export class MetaService implements OnApplicationShutdown { 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); + await transactionalEntityManager.save(MiMeta, { + ...data, + id: 'x', + }); } + + const afters = await transactionalEntityManager.find(MiMeta, { + order: { + id: 'DESC', + }, + }); + + // Propagate changes to blockedHosts, silencedHosts, mediaSilencedHosts, federationInstances, and bubbleInstances to the relevant instance rows + // Do this inside the transaction to avoid potential race condition (when an instance gets registered while we're updating). + await this.persistBlocks(transactionalEntityManager, before ?? {}, afters[0]); + + return afters[0]; }); if (data.hiddenTags) { @@ -156,4 +169,49 @@ export class MetaService implements OnApplicationShutdown { public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); } + + private async persistBlocks(tem: EntityManager, before: Partial, after: Partial): Promise { + await this.persistBlock(tem, before.blockedHosts, after.blockedHosts, 'isBlocked'); + await this.persistBlock(tem, before.silencedHosts, after.silencedHosts, 'isSilenced'); + await this.persistBlock(tem, before.mediaSilencedHosts, after.mediaSilencedHosts, 'isMediaSilenced'); + await this.persistBlock(tem, before.federationHosts, after.federationHosts, 'isAllowListed'); + await this.persistBlock(tem, before.bubbleInstances, after.bubbleInstances, 'isBubbled'); + } + + private async persistBlock(tem: EntityManager, before: string[] | undefined, after: string[] | undefined, field: keyof MiInstance): Promise { + const { added, removed } = diffArrays(before, after); + + if (removed.length > 0) { + await this.updateInstancesByHost(tem, field, false, removed); + } + + if (added.length > 0) { + await this.updateInstancesByHost(tem, field, true, added); + } + } + + private async updateInstancesByHost(tem: EntityManager, field: keyof MiInstance, value: boolean, hosts: string[]): Promise { + // Use non-array queries when possible, as they are indexed and can be much faster. + if (hosts.length === 1) { + const pattern = genHostPattern(hosts[0]); + await tem + .createQueryBuilder(MiInstance, 'instance') + .update() + .set({ [field]: value }) + .where('(lower(reverse("host")) || \'.\') LIKE :pattern', { pattern }) + .execute(); + } else if (hosts.length > 1) { + const patterns = hosts.map(host => genHostPattern(host)); + await tem + .createQueryBuilder(MiInstance, 'instance') + .update() + .set({ [field]: value }) + .where('(lower(reverse("host")) || \'.\') LIKE ANY (:patterns)', { patterns }) + .execute(); + } + } +} + +function genHostPattern(host: string): string { + return host.toLowerCase().split('').reverse().join('') + '.%'; } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 6c2f673217..839cdf534c 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -5,24 +5,23 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import * as parse5 from 'parse5'; -import { Window, XMLSerializer } from 'happy-dom'; +import { isText, isTag, Text } from 'domhandler'; +import * as htmlparser2 from 'htmlparser2'; +import { Node, Document, ChildNode, Element, ParentNode } from 'domhandler'; +import * as domserializer from 'dom-serializer'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; -import type { DefaultTreeAdapterMap } from 'parse5'; -import type * as mfm from '@transfem-org/sfm-js'; - -const treeAdapter = parse5.defaultTreeAdapter; -type Node = DefaultTreeAdapterMap['node']; -type ChildNode = DefaultTreeAdapterMap['childNode']; +import type * as mfm from 'mfm-js'; const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; +export type Appender = (document: Document, body: Element) => void; + @Injectable() export class MfmService { constructor( @@ -38,7 +37,7 @@ export class MfmService { const normalizedHashtagNames = hashtagNames == null ? undefined : new Set(hashtagNames.map(x => normalizeForSearch(x))); - const dom = parse5.parseFragment(html); + const dom = htmlparser2.parseDocument(html); let text = ''; @@ -49,57 +48,50 @@ export class MfmService { return text.trim(); function getText(node: Node): string { - if (treeAdapter.isTextNode(node)) return node.value; - if (!treeAdapter.isElementNode(node)) return ''; - if (node.nodeName === 'br') return '\n'; + if (isText(node)) return node.data; + if (!isTag(node)) return ''; + if (node.tagName === 'br') return '\n'; - if (node.childNodes) { - return node.childNodes.map(n => getText(n)).join(''); - } - - return ''; + return node.childNodes.map(n => getText(n)).join(''); } function appendChildren(childNodes: ChildNode[]): void { - if (childNodes) { - for (const n of childNodes) { - analyze(n); - } + for (const n of childNodes) { + analyze(n); } } function analyze(node: Node) { - if (treeAdapter.isTextNode(node)) { - text += node.value; + if (isText(node)) { + text += node.data; return; } // Skip comment or document type node - if (!treeAdapter.isElementNode(node)) { + if (!isTag(node)) { return; } - switch (node.nodeName) { + switch (node.tagName) { case 'br': { text += '\n'; - break; + return; } - case 'a': { const txt = getText(node); - const rel = node.attrs.find(x => x.name === 'rel'); - const href = node.attrs.find(x => x.name === 'href'); + const rel = node.attribs.rel; + const href = node.attribs.href; // ハッシュタグ if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { text += txt; // メンション - } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { + } else if (txt.startsWith('@') && !(rel && rel.startsWith('me '))) { const part = txt.split('@'); if (part.length === 2 && href) { //#region ホスト名部分が省略されているので復元する - const acct = `${txt}@${(new URL(href.value)).hostname}`; + const acct = `${txt}@${(new URL(href)).hostname}`; text += acct; //#endregion } else if (part.length === 3) { @@ -114,25 +106,32 @@ export class MfmService { if (!href) { return txt; } - if (!txt || txt === href.value) { // #6383: Missing text node - if (href.value.match(urlRegexFull)) { - return href.value; + if (!txt || txt === href) { // #6383: Missing text node + if (href.match(urlRegexFull)) { + return href; } else { - return `<${href.value}>`; + return `<${href}>`; } } - if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { - return `[${txt}](<${href.value}>)`; // #6846 + if (href.match(urlRegex) && !href.match(urlRegexFull)) { + return `[${txt}](<${href}>)`; // #6846 } else { - return `[${txt}](${href.value})`; + return `[${txt}](${href})`; } }; text += generateLink(); } - break; + return; } + } + // Don't produce invalid empty MFM + if (node.childNodes.length < 1) { + return; + } + + switch (node.tagName) { case 'h1': { text += '**【'; appendChildren(node.childNodes); @@ -179,18 +178,21 @@ export class MfmService { break; } - // this is here only to catch upstream changes! + // this is here only to catch upstream changes! case 'ruby--': { let ruby: [string, string][] = []; for (const child of node.childNodes) { - if (child.nodeName === 'rp') { + if (isText(child) && !/\s|\[|\]/.test(child.data)) { + ruby.push([child.data, '']); continue; } - if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) { - ruby.push([child.value, '']); + if (!isTag(child)) { continue; } - if (child.nodeName === 'rt' && ruby.length > 0) { + if (child.tagName === 'rp') { + continue; + } + if (child.tagName === 'rt' && ruby.length > 0) { const rt = getText(child); if (/\s|\[|\]/.test(rt)) { // If any space is included in rt, it is treated as a normal text @@ -215,7 +217,7 @@ export class MfmService { // block code (
)
 				case 'pre': {
-					if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
+					if (node.childNodes.length === 1 && isTag(node.childNodes[0]) && node.childNodes[0].tagName === 'code') {
 						text += '\n```\n';
 						text += getText(node.childNodes[0]);
 						text += '\n```\n';
@@ -300,17 +302,17 @@ export class MfmService {
 						let nonRtNodes = [];
 						// scan children, ignore `rp`, split on `rt`
 						for (const child of node.childNodes) {
-							if (treeAdapter.isTextNode(child)) {
+							if (isText(child)) {
 								nonRtNodes.push(child);
 								continue;
 							}
-							if (!treeAdapter.isElementNode(child)) {
+							if (!isTag(child)) {
 								continue;
 							}
-							if (child.nodeName === 'rp') {
+							if (child.tagName === 'rp') {
 								continue;
 							}
-							if (child.nodeName === 'rt') {
+							if (child.tagName === 'rt') {
 								// the only case in which we don't need a `$[group ]`
 								// is when both sides of the ruby are simple words
 								const needsGroup = nonRtNodes.length > 1 ||
@@ -343,50 +345,49 @@ export class MfmService {
 	}
 
 	@bindThis
-	public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
+	public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
 		if (nodes == null) {
 			return null;
 		}
 
-		const { happyDOM, window } = new Window();
+		const doc = new Document([]);
 
-		const doc = window.document;
+		const body = new Element('p', {});
+		doc.childNodes.push(body);
 
-		const body = doc.createElement('p');
-
-		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 appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
+			for (const child of children.map(x => handle(x))) {
+				targetElement.childNodes.push(child);
 			}
 		}
 
 		function fnDefault(node: mfm.MfmFn) {
-			const el = doc.createElement('i');
+			const el = new Element('i', {});
 			appendChildren(node.children, el);
 			return el;
 		}
 
-		const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
+		const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => ChildNode } = {
 			bold: (node) => {
-				const el = doc.createElement('b');
+				const el = new Element('b', {});
 				appendChildren(node.children, el);
 				return el;
 			},
 
 			small: (node) => {
-				const el = doc.createElement('small');
+				const el = new Element('small', {});
 				appendChildren(node.children, el);
 				return el;
 			},
 
 			strike: (node) => {
-				const el = doc.createElement('del');
+				const el = new Element('del', {});
 				appendChildren(node.children, el);
 				return el;
 			},
 
 			italic: (node) => {
-				const el = doc.createElement('i');
+				const el = new Element('i', {});
 				appendChildren(node.children, el);
 				return el;
 			},
@@ -397,11 +398,12 @@ export class MfmService {
 						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();
+							const el = new Element('time', {
+								datetime: date.toISOString(),
+							});
+							el.childNodes.push(new Text(date.toISOString()));
 							return el;
-						} catch (err) {
+						} catch {
 							return fnDefault(node);
 						}
 					}
@@ -410,20 +412,20 @@ export class MfmService {
 						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');
+							const rubyEl = new Element('ruby', {});
+							const rtEl = new Element('rt', {});
 
 							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
+							const rpStartEl = new Element('rp', {});
+							rpStartEl.childNodes.push(new Text('('));
+							const rpEndEl = new Element('rp', {});
+							rpEndEl.childNodes.push(new Text(')'));
 
-							rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
-							rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
-							rubyEl.appendChild(rpStartEl);
-							rubyEl.appendChild(rtEl);
-							rubyEl.appendChild(rpEndEl);
+							rubyEl.childNodes.push(new Text(text.split(' ')[0]));
+							rtEl.childNodes.push(new Text(text.split(' ')[1]));
+							rubyEl.childNodes.push(rpStartEl);
+							rubyEl.childNodes.push(rtEl);
+							rubyEl.childNodes.push(rpEndEl);
 							return rubyEl;
 						} else {
 							const rt = node.children.at(-1);
@@ -433,20 +435,20 @@ export class MfmService {
 							}
 
 							const text = rt.type === 'text' ? rt.props.text : '';
-							const rubyEl = doc.createElement('ruby');
-							const rtEl = doc.createElement('rt');
+							const rubyEl = new Element('ruby', {});
+							const rtEl = new Element('rt', {});
 
 							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
+							const rpStartEl = new Element('rp', {});
+							rpStartEl.childNodes.push(new Text('('));
+							const rpEndEl = new Element('rp', {});
+							rpEndEl.childNodes.push(new Text(')'));
 
 							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);
+							rtEl.childNodes.push(new Text(text.trim()));
+							rubyEl.childNodes.push(rpStartEl);
+							rubyEl.childNodes.push(rtEl);
+							rubyEl.childNodes.push(rpEndEl);
 							return rubyEl;
 						}
 					}
@@ -454,7 +456,7 @@ export class MfmService {
 					// hack for ruby, should never be needed because we should
 					// never send this out to other instances
 					case 'group': {
-						const el = doc.createElement('span');
+						const el = new Element('span', {});
 						appendChildren(node.children, el);
 						return el;
 					}
@@ -466,394 +468,426 @@ export class MfmService {
 			},
 
 			blockCode: (node) => {
-				const pre = doc.createElement('pre');
-				const inner = doc.createElement('code');
-				inner.textContent = node.props.code;
-				pre.appendChild(inner);
+				const pre = new Element('pre', {});
+				const inner = new Element('code', {});
+				inner.childNodes.push(new Text(node.props.code));
+				pre.childNodes.push(inner);
 				return pre;
 			},
 
 			center: (node) => {
-				const el = doc.createElement('div');
+				const el = new Element('div', {});
 				appendChildren(node.children, el);
 				return el;
 			},
 
 			emojiCode: (node) => {
-				return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+				return new Text(`\u200B:${node.props.name}:\u200B`);
 			},
 
 			unicodeEmoji: (node) => {
-				return doc.createTextNode(node.props.emoji);
+				return new Text(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');
+				const a = new Element('a', {
+					href: `${this.config.url}/tags/${node.props.hashtag}`,
+					rel: 'tag',
+				});
+				a.childNodes.push(new Text(`#${node.props.hashtag}`));
 				return a;
 			},
 
 			inlineCode: (node) => {
-				const el = doc.createElement('code');
-				el.textContent = node.props.code;
+				const el = new Element('code', {});
+				el.childNodes.push(new Text(node.props.code));
 				return el;
 			},
 
 			mathInline: (node) => {
-				const el = doc.createElement('code');
-				el.textContent = node.props.formula;
+				const el = new Element('code', {});
+				el.childNodes.push(new Text(node.props.formula));
 				return el;
 			},
 
 			mathBlock: (node) => {
-				const el = doc.createElement('code');
-				el.textContent = node.props.formula;
+				const el = new Element('code', {});
+				el.childNodes.push(new Text(node.props.formula));
 				return el;
 			},
 
 			link: (node) => {
-				const a = doc.createElement('a');
-				a.setAttribute('href', node.props.url);
+				const a = new Element('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.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
-				a.setAttribute('href', remoteUserInfo
-					? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
-					: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
-				a.className = 'u-url mention';
-				a.textContent = acct;
+
+				const a = new Element('a', {
+					href: remoteUserInfo
+						? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
+						: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`,
+					class: 'u-url mention',
+				});
+				a.childNodes.push(new Text(acct));
 				return a;
 			},
 
 			quote: (node) => {
-				const el = doc.createElement('blockquote');
+				const el = new Element('blockquote', {});
 				appendChildren(node.children, el);
 				return el;
 			},
 
 			text: (node) => {
 				if (!node.props.text.match(/[\r\n]/)) {
-					return doc.createTextNode(node.props.text);
+					return new Text(node.props.text);
 				}
 
-				const el = doc.createElement('span');
-				const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
+				const el = new Element('span', {});
+				const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => new Text(x));
 
 				for (const x of intersperse('br', nodes)) {
-					el.appendChild(x === 'br' ? doc.createElement('br') : x);
+					el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
 				}
 
 				return el;
 			},
 
 			url: (node) => {
-				const a = doc.createElement('a');
-				a.setAttribute('href', node.props.url);
-				a.textContent = node.props.url;
+				const a = new Element('a', {
+					href: node.props.url,
+				});
+				a.childNodes.push(new Text(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;
+				const a = new Element('a', {
+					href: `https://www.google.com/search?q=${node.props.query}`,
+				});
+				a.childNodes.push(new Text(node.props.content));
 				return a;
 			},
 
 			plain: (node) => {
-				const el = doc.createElement('span');
+				const el = new Element('span', {});
 				appendChildren(node.children, el);
 				return el;
 			},
 		};
 
+		// Utility function to make TypeScript behave
+		function handle(node: T): ChildNode {
+			const handler = handlers[node.type] as (node: T) => ChildNode;
+			return handler(node);
+		}
+
 		appendChildren(nodes, body);
 
-		const serialized = new XMLSerializer().serializeToString(body);
+		for (const additionalAppender of additionalAppenders) {
+			additionalAppender(doc, body);
+		}
 
-		happyDOM.close().catch(err => {});
-
-		return serialized;
+		return domserializer.render(body, {
+			encodeEntities: 'utf8'
+		});
 	}
 
 	// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
+	// additionally modified by hazelnoot to remove async
 
 	@bindThis
-	public async toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) {
+	public toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) {
 		if (nodes == null) {
 			return null;
 		}
 
-		const { happyDOM, window } = new Window();
+		const doc = new Document([]);
 
-		const doc = window.document;
+		const body = new Element('p', {});
+		doc.childNodes.push(body);
 
-		const body = doc.createElement('p');
-
-		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);
+		function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
+			for (const child of children) {
+				const result = handle(child);
+				targetElement.childNodes.push(result);
 			}
 		}
 
 		const handlers: {
-			[K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any;
+			[K in mfm.MfmNode['type']]: (node: mfm.NodeType) => ChildNode;
 		} = {
-			async bold(node) {
-				const el = doc.createElement('span');
-				el.textContent = '**';
-				await appendChildren(node.children, el);
-				el.textContent += '**';
+			bold(node) {
+				const el = new Element('span', {});
+				el.childNodes.push(new Text('**'));
+				appendChildren(node.children, el);
+				el.childNodes.push(new Text('**'));
 				return el;
 			},
 
-			async small(node) {
-				const el = doc.createElement('small');
-				await appendChildren(node.children, el);
+			small(node) {
+				const el = new Element('small', {});
+				appendChildren(node.children, el);
 				return el;
 			},
 
-			async strike(node) {
-				const el = doc.createElement('span');
-				el.textContent = '~~';
-				await appendChildren(node.children, el);
-				el.textContent += '~~';
+			strike(node) {
+				const el = new Element('span', {});
+				el.childNodes.push(new Text('~~'));
+				appendChildren(node.children, el);
+				el.childNodes.push(new Text('~~'));
 				return el;
 			},
 
-			async italic(node) {
-				const el = doc.createElement('span');
-				el.textContent = '*';
-				await appendChildren(node.children, el);
-				el.textContent += '*';
+			italic(node) {
+				const el = new Element('span', {});
+				el.childNodes.push(new Text('*'));
+				appendChildren(node.children, el);
+				el.childNodes.push(new Text('*'));
 				return el;
 			},
 
-			async fn(node) {
+			fn(node) {
 				switch (node.props.name) {
 					case 'group': { // hack for ruby
-						const el = doc.createElement('span');
-						await appendChildren(node.children, el);
+						const el = new Element('span', {});
+						appendChildren(node.children, el);
 						return el;
 					}
 					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');
+							const rubyEl = new Element('ruby', {});
+							const rtEl = new Element('rt', {});
 
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
+							const rpStartEl = new Element('rp', {});
+							rpStartEl.childNodes.push(new Text('('));
+							const rpEndEl = new Element('rp', {});
+							rpEndEl.childNodes.push(new Text(')'));
 
-							rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
-							rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
-							rubyEl.appendChild(rpStartEl);
-							rubyEl.appendChild(rtEl);
-							rubyEl.appendChild(rpEndEl);
+							rubyEl.childNodes.push(new Text(text.split(' ')[0]));
+							rtEl.childNodes.push(new Text(text.split(' ')[1]));
+							rubyEl.childNodes.push(rpStartEl);
+							rubyEl.childNodes.push(rtEl);
+							rubyEl.childNodes.push(rpEndEl);
 							return rubyEl;
 						} else {
 							const rt = node.children.at(-1);
 
 							if (!rt) {
-								const el = doc.createElement('span');
-								await appendChildren(node.children, el);
+								const el = new Element('span', {});
+								appendChildren(node.children, el);
 								return el;
 							}
 
 							const text = rt.type === 'text' ? rt.props.text : '';
-							const rubyEl = doc.createElement('ruby');
-							const rtEl = doc.createElement('rt');
+							const rubyEl = new Element('ruby', {});
+							const rtEl = new Element('rt', {});
 
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
+							const rpStartEl = new Element('rp', {});
+							rpStartEl.childNodes.push(new Text('('));
+							const rpEndEl = new Element('rp', {});
+							rpEndEl.childNodes.push(new Text(')'));
 
-							await 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);
+							appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
+							rtEl.childNodes.push(new Text(text.trim()));
+							rubyEl.childNodes.push(rpStartEl);
+							rubyEl.childNodes.push(rtEl);
+							rubyEl.childNodes.push(rpEndEl);
 							return rubyEl;
 						}
 					}
 
 					default: {
-						const el = doc.createElement('span');
-						el.textContent = '*';
-						await appendChildren(node.children, el);
-						el.textContent += '*';
+						const el = new Element('span', {});
+						el.childNodes.push(new Text('*'));
+						appendChildren(node.children, el);
+						el.childNodes.push(new Text('*'));
 						return el;
 					}
 				}
 			},
 
 			blockCode(node) {
-				const pre = doc.createElement('pre');
-				const inner = doc.createElement('code');
+				const pre = new Element('pre', {});
+				const inner = new Element('code', {});
 
 				const nodes = node.props.code
 					.split(/\r\n|\r|\n/)
-					.map((x) => doc.createTextNode(x));
+					.map((x) => new Text(x));
 
 				for (const x of intersperse('br', nodes)) {
-					inner.appendChild(x === 'br' ? doc.createElement('br') : x);
+					inner.childNodes.push(x === 'br' ? new Element('br', {}) : x);
 				}
 
-				pre.appendChild(inner);
+				pre.childNodes.push(inner);
 				return pre;
 			},
 
-			async center(node) {
-				const el = doc.createElement('div');
-				await appendChildren(node.children, el);
+			center(node) {
+				const el = new Element('div', {});
+				appendChildren(node.children, el);
 				return el;
 			},
 
 			emojiCode(node) {
-				return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+				return new Text(`\u200B:${node.props.name}:\u200B`);
 			},
 
 			unicodeEmoji(node) {
-				return doc.createTextNode(node.props.emoji);
+				return new Text(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');
+				const a = new Element('a', {
+					href: `${this.config.url}/tags/${node.props.hashtag}`,
+					rel: 'tag',
+					class: 'hashtag',
+				});
+				a.childNodes.push(new Text(`#${node.props.hashtag}`));
 				return a;
 			},
 
 			inlineCode(node) {
-				const el = doc.createElement('code');
-				el.textContent = node.props.code;
+				const el = new Element('code', {});
+				el.childNodes.push(new Text(node.props.code));
 				return el;
 			},
 
 			mathInline(node) {
-				const el = doc.createElement('code');
-				el.textContent = node.props.formula;
+				const el = new Element('code', {});
+				el.childNodes.push(new Text(node.props.formula));
 				return el;
 			},
 
 			mathBlock(node) {
-				const el = doc.createElement('code');
-				el.textContent = node.props.formula;
+				const el = new Element('code', {});
+				el.childNodes.push(new Text(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);
+			link(node) {
+				const a = new Element('a', {
+					rel: 'nofollow noopener noreferrer',
+					target: '_blank',
+					href: node.props.url,
+				});
+				appendChildren(node.children, a);
 				return a;
 			},
 
-			async mention(node) {
+			mention(node) {
 				const { username, host, acct } = node.props;
 				const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
 
-				const el = doc.createElement('span');
+				const el = new Element('span', {});
 				if (!resolved) {
-					el.textContent = acct;
+					el.childNodes.push(new Text(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);
+					el.attribs.class = 'h-card';
+					el.attribs.translate = 'no';
+					const a = new Element('a', {
+						href: resolved.url ? resolved.url : resolved.uri,
+						class: 'u-url mention',
+					});
+					const span = new Element('span', {});
+					span.childNodes.push(new Text(resolved.username || username));
+					a.childNodes.push(new Text('@'));
+					a.childNodes.push(span);
+					el.childNodes.push(a);
 				}
 
 				return el;
 			},
 
-			async quote(node) {
-				const el = doc.createElement('blockquote');
-				await appendChildren(node.children, el);
+			quote(node) {
+				const el = new Element('blockquote', {});
+				appendChildren(node.children, el);
 				return el;
 			},
 
 			text(node) {
-				const el = doc.createElement('span');
+				if (!node.props.text.match(/[\r\n]/)) {
+					return new Text(node.props.text);
+				}
+
+				const el = new Element('span', {});
 				const nodes = node.props.text
 					.split(/\r\n|\r|\n/)
-					.map((x) => doc.createTextNode(x));
+					.map((x) => new Text(x));
 
 				for (const x of intersperse('br', nodes)) {
-					el.appendChild(x === 'br' ? doc.createElement('br') : x);
+					el.childNodes.push(x === 'br' ? new Element('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?:\/\//, '');
+				const a = new Element('a', {
+					rel: 'nofollow noopener noreferrer',
+					target: '_blank',
+					href: node.props.url,
+				});
+				a.childNodes.push(new Text(node.props.url.replace(/^https?:\/\//, '')));
 				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;
+				const a = new Element('a', {
+					href: `https://www.google.com/search?q=${node.props.query}`,
+				});
+				a.childNodes.push(new Text(node.props.content));
 				return a;
 			},
 
-			async plain(node) {
-				const el = doc.createElement('span');
-				await appendChildren(node.children, el);
+			plain(node) {
+				const el = new Element('span', {});
+				appendChildren(node.children, el);
 				return el;
 			},
 		};
 
-		await appendChildren(nodes, 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);
-
-			body.appendChild(quote);
+		// Utility function to make TypeScript behave
+		function handle(node: T): ChildNode {
+			const handler = handlers[node.type] as (node: T) => ChildNode;
+			return handler(node);
 		}
 
-		let result = new XMLSerializer().serializeToString(body);
+		appendChildren(nodes, body);
+
+		if (quoteUri !== null) {
+			const a = new Element('a', {
+				href: quoteUri,
+			});
+			a.childNodes.push(new Text(quoteUri.replace(/^https?:\/\//, '')));
+
+			const quote = new Element('span', {
+				class: 'quote-inline',
+			});
+			quote.childNodes.push(new Element('br', {}));
+			quote.childNodes.push(new Element('br', {}));
+			quote.childNodes.push(new Text('RE: '));
+			quote.childNodes.push(a);
+
+			body.childNodes.push(quote);
+		}
+
+		let result = domserializer.render(body, {
+			encodeEntities: 'utf8'
+		});
 
 		if (inline) {
 			result = result.replace(/^

/, '').replace(/<\/p>$/, ''); } - happyDOM.close().catch(e => {}); - return result; } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index df31cb4247..f4159facc3 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -4,7 +4,7 @@ */ import { setImmediate } from 'node:timers/promises'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import { In, DataSource, IsNull, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; @@ -42,7 +42,6 @@ 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 { RoleService } from '@/core/RoleService.js'; @@ -52,7 +51,7 @@ 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'; -import { trackPromise } from '@/misc/promise-tracker.js'; +import { trackTask } from '@/misc/promise-tracker.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { LatestNoteService } from '@/core/LatestNoteService.js'; @@ -203,7 +202,6 @@ export class NoteCreateService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private queueService: QueueService, private fanoutTimelineService: FanoutTimelineService, - private noteReadService: NoteReadService, private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, @@ -298,7 +296,7 @@ export class NoteCreateService implements OnApplicationShutdown { case 'followers': // 他人のfollowers noteはreject if (data.renote.userId !== user.id) { - throw new Error('Renote target is not public or home'); + throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home'); } // Renote対象がfollowersならfollowersにする @@ -306,7 +304,7 @@ export class NoteCreateService implements OnApplicationShutdown { break; case 'specified': // specified / direct noteはreject - throw new Error('Renote target is not public or home'); + throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home'); } } @@ -319,7 +317,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.renote.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); if (blocked) { - throw new Error('blocked'); + throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is blocked'); } } } @@ -491,10 +489,10 @@ export class NoteCreateService implements OnApplicationShutdown { // should really not happen, but better safe than sorry if (data.reply?.id === insert.id) { - throw new Error('A note can\'t reply to itself'); + throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', 'A note can\'t reply to itself'); } if (data.renote?.id === insert.id) { - throw new Error('A note can\'t renote itself'); + throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', 'A note can\'t renote itself'); } if (data.uri != null) insert.uri = data.uri; @@ -541,7 +539,8 @@ export class NoteCreateService implements OnApplicationShutdown { await this.notesRepository.insert(insert); } - return insert; + // Re-fetch note to get the default values of null / unset fields. + return await this.notesRepository.findOneByOrFail({ id: insert.id }); } catch (e) { // duplicate key error if (isDuplicateKeyValueError(e)) { @@ -550,8 +549,6 @@ export class NoteCreateService implements OnApplicationShutdown { throw err; } - console.error(e); - throw e; } } @@ -573,7 +570,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (this.meta.enableStatsForFederatedInstances) { if (this.userEntityService.isRemoteUser(user)) { this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { - if (note.renote && note.text || !note.renote) { + if (!this.isRenote(note) || this.isQuote(note)) { this.updateNotesCountQueue.enqueue(i.id, 1); } if (this.meta.enableChartsForFederatedInstances) { @@ -585,35 +582,35 @@ export class NoteCreateService implements OnApplicationShutdown { // ハッシュタグ更新 if (data.visibility === 'public' || data.visibility === 'home') { - if (user.isBot && this.meta.enableBotTrending) { - this.hashtagService.updateHashtags(user, tags); - } else if (!user.isBot) { + if (!user.isBot || this.meta.enableBotTrending) { this.hashtagService.updateHashtags(user, tags); } } - if (data.renote && data.text) { - // Increment notes count (user) - this.incNotesCountOfUser(user); - } else if (!data.renote) { + if (!this.isRenote(note) || this.isQuote(note)) { // Increment notes count (user) this.incNotesCountOfUser(user); + } else { + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); } this.pushToTl(note, user); - this.antennaService.addNoteToAntennas(note, user); + this.antennaService.addNoteToAntennas({ + ...note, + channel: data.channel ?? null, + }, user); if (data.reply) { this.saveReply(data.reply, note); } if (data.reply == null) { - // TODO: キャッシュ - this.followingsRepository.findBy({ - followeeId: user.id, - notify: 'normal', - }).then(async followings => { + this.cacheService.userFollowersCache.fetch(user.id).then(async followingsMap => { + const followings = Array + .from(followingsMap.values()) + .filter(f => f.notify === 'normal'); + if (note.visibility !== 'specified') { const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false; for (const following of followings) { @@ -633,8 +630,8 @@ export class NoteCreateService implements OnApplicationShutdown { }); } - if (data.renote && data.text == null && data.renote.userId !== user.id && !user.isBot) { - this.incRenoteCount(data.renote); + if (this.isRenote(data) && !this.isQuote(data) && data.renote.userId !== user.id && !user.isBot) { + this.incRenoteCount(data.renote, user); } if (data.poll && data.poll.expiresAt) { @@ -644,38 +641,20 @@ export class NoteCreateService implements OnApplicationShutdown { }, { jobId: `pollEnd:${note.id}`, delay, - removeOnComplete: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } 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 }); @@ -733,13 +712,7 @@ export class NoteCreateService implements OnApplicationShutdown { }, }); - const [ - userIdsWhoMeMuting, - ] = data.renote.userId ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(data.renote.userId), - ]) : [new Set()]; - - const muted = isUserRelated(note, userIdsWhoMeMuting); + const muted = data.renote.userId && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(data.renote.userId)); if (!isThreadMuted && !muted) { nm.push(data.renote.userId, type); @@ -757,8 +730,8 @@ export class NoteCreateService implements OnApplicationShutdown { //#region AP deliver if (!data.localOnly && this.userEntityService.isLocalUser(user)) { - (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user); + trackTask(async () => { + const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote }); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); // メンションされたリモートユーザーに配送 @@ -783,12 +756,12 @@ export class NoteCreateService implements OnApplicationShutdown { dm.addFollowersRecipe(); } - if (['public'].includes(note.visibility)) { - this.relayService.deliverToRelays(user, noteActivity); - } + await dm.execute(); - trackPromise(dm.execute()); - })(); + if (['public'].includes(note.visibility)) { + await this.relayService.deliverToRelays(user, noteActivity); + } + }); } //#endregion } @@ -841,8 +814,8 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private incRenoteCount(renote: MiNote) { - this.notesRepository.createQueryBuilder().update() + private async incRenoteCount(renote: MiNote, user: MiUser) { + await this.notesRepository.createQueryBuilder().update() .set({ renoteCount: () => '"renoteCount" + 1', }) @@ -850,15 +823,18 @@ export class NoteCreateService implements OnApplicationShutdown { .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); + if (user.isExplorable && Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { + const policies = await this.roleService.getUserPolicies(user); + if (policies.canTrend) { + if (renote.channelId != null) { + if (renote.replyId == null) { + this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5); + } + } else { + if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { + this.featuredService.updateGlobalNotesRanking(renote, 5); + this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5); + } } } } @@ -866,7 +842,8 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) { - for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { + // Only create mention events for local users, and users for whom the note is visible + for (const u of mentionedUsers.filter(u => (note.visibility !== 'specified' || note.visibleUserIds.some(x => x === u.id)) && this.userEntityService.isLocalUser(u))) { const isThreadMuted = await this.noteThreadMutingsRepository.exists({ where: { userId: u.id, @@ -874,13 +851,7 @@ export class NoteCreateService implements OnApplicationShutdown { }, }); - const [ - userIdsWhoMeMuting, - ] = u.id ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(u.id), - ]) : [new Set()]; - - const muted = isUserRelated(note, userIdsWhoMeMuting); + const muted = u.id && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(u.id)); if (isThreadMuted || muted) { continue; @@ -903,17 +874,6 @@ export class NoteCreateService implements OnApplicationShutdown { this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1); } - @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) { - if (data.localOnly) return null; - - const content = this.isRenote(data) && !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, user, false), note); - - return this.apRendererService.addContext(content); - } - @bindThis private index(note: MiNote) { if (note.text == null && note.cw == null) return; @@ -977,14 +937,7 @@ export class NoteCreateService implements OnApplicationShutdown { // 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.cacheService.getNonHibernatedFollowers(user.id), this.userListMembershipsRepository.find({ where: { userId: user.id, @@ -1000,6 +953,7 @@ export class NoteCreateService implements OnApplicationShutdown { // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする for (const following of followings) { + if (following.followerHost !== null) continue; // 基本的にvisibleUserIdsには自身のidが含まれている前提であること if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; @@ -1101,17 +1055,19 @@ export class NoteCreateService implements OnApplicationShutdown { }); 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, - }); + await Promise.all([ + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }), + this.followingsRepository.update({ + followerId: In(hibernatedUsers.map(x => x.id)), + }, { + isFollowerHibernated: true, + }), + this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])), + ]); } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 1f94e65809..9ce8cb6731 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets, In } from 'typeorm'; +import { Brackets, In, IsNull, Not } from 'typeorm'; import { Injectable, Inject } from '@nestjs/common'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; @@ -124,9 +124,11 @@ export class NoteDeleteService { this.perUserNotesChart.update(user, note, false); } - if (note.renoteId && note.text || !note.renoteId) { + if (!isRenote(note) || isQuote(note)) { // Decrement notes count (user) this.decNotesCountOfUser(user); + } else { + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); } if (this.meta.enableStatsForFederatedInstances) { @@ -165,8 +167,11 @@ export class NoteDeleteService { }); } - if (note.uri) { - this.apLogService.deleteObjectLogs(note.uri) + const deletedUris = [note, ...cascadingNotes] + .map(n => n.uri) + .filter((u): u is string => u != null); + if (deletedUris.length > 0) { + this.apLogService.deleteObjectLogs(deletedUris) .catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`)); } } @@ -231,13 +236,27 @@ export class NoteDeleteService { }) as MiRemoteUser[]; } + @bindThis + private async getRenotedOrRepliedRemoteUsers(note: MiNote) { + const query = this.notesRepository.createQueryBuilder('note') + .leftJoinAndSelect('note.user', 'user') + .where(new Brackets(qb => { + qb.orWhere('note.renoteId = :renoteId', { renoteId: note.id }); + qb.orWhere('note.replyId = :replyId', { replyId: note.id }); + })) + .andWhere({ userHost: Not(IsNull()) }); + const notes = await query.getMany() as (MiNote & { user: MiRemoteUser })[]; + const remoteUsers = notes.map(({ user }) => user); + return remoteUsers; + } + @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); - } + await this.apDeliverManagerService.deliverToFollowers(user, content); + await this.apDeliverManagerService.deliverToUsers(user, content, [ + ...await this.getMentionedRemoteUsers(note), + ...await this.getRenotedOrRepliedRemoteUsers(note), + ]); + await this.relayService.deliverToRelays(user, content); } } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 7851af86b7..4be097465d 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -4,10 +4,11 @@ */ import { setImmediate } from 'node:timers/promises'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import { DataSource, In, IsNull, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { UnrecoverableError } from 'bullmq'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; @@ -36,7 +37,6 @@ 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 { RoleService } from '@/core/RoleService.js'; @@ -46,7 +46,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; -import { trackPromise } from '@/misc/promise-tracker.js'; +import { trackTask } from '@/misc/promise-tracker.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { LatestNoteService } from '@/core/LatestNoteService.js'; @@ -203,7 +203,6 @@ export class NoteEditService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private queueService: QueueService, private fanoutTimelineService: FanoutTimelineService, - private noteReadService: NoteReadService, private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, @@ -233,7 +232,7 @@ export class NoteEditService implements OnApplicationShutdown { noindex: MiUser['noindex']; }, editid: MiNote['id'], data: Option, silent = false): Promise { if (!editid) { - throw new Error('fail'); + throw new UnrecoverableError('edit failed: missing editid'); } const oldnote = await this.notesRepository.findOneBy({ @@ -241,11 +240,11 @@ export class NoteEditService implements OnApplicationShutdown { }); if (oldnote == null) { - throw new Error('no such note'); + throw new UnrecoverableError(`edit failed for ${editid}: missing oldnote`); } if (oldnote.userId !== user.id) { - throw new Error('not the author'); + throw new UnrecoverableError(`edit failed for ${editid}: user is not the note author`); } // we never want to change the replyId, so fetch the original "parent" @@ -276,12 +275,12 @@ export class NoteEditService implements OnApplicationShutdown { data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); } + if (data.updatedAt == null) data.updatedAt = 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 (data.updatedAt == null) data.updatedAt = new Date(); if (data.visibility === 'public' && data.channel == null) { const sensitiveWords = this.meta.sensitiveWords; @@ -310,7 +309,7 @@ export class NoteEditService implements OnApplicationShutdown { if (this.isRenote(data)) { if (data.renote.id === oldnote.id) { - throw new Error('A note can\'t renote itself'); + throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldnote.id}: cannot renote itself`); } switch (data.renote.visibility) { @@ -326,7 +325,7 @@ export class NoteEditService implements OnApplicationShutdown { case 'followers': // 他人のfollowers noteはreject if (data.renote.userId !== user.id) { - throw new Error('Renote target is not public or home'); + throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home'); } // Renote対象がfollowersならfollowersにする @@ -334,7 +333,7 @@ export class NoteEditService implements OnApplicationShutdown { break; case 'specified': // specified / direct noteはreject - throw new Error('Renote target is not public or home'); + throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home'); } } @@ -406,10 +405,10 @@ export class NoteEditService implements OnApplicationShutdown { // 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 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)!)) + ? concat(data.poll.choices.map(choice => mfm.parse(choice))) : []; const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); @@ -424,7 +423,7 @@ export class NoteEditService implements OnApplicationShutdown { // if the host is media-silenced, custom emojis are not allowed if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; - tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); + 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 })); @@ -467,10 +466,8 @@ export class NoteEditService implements OnApplicationShutdown { update.hasPoll = !!data.poll; } - // technically we should check if the two sets of files are - // different, or if their descriptions have changed. In practice - // this is good enough. - const filesChanged = oldnote.fileIds?.length || data.files?.length; + // TODO deep-compare files + const filesChanged = oldnote.fileIds.length || data.files?.length; const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id }); @@ -564,7 +561,7 @@ export class NoteEditService implements OnApplicationShutdown { noteVisibility: note.visibility, userId: user.id, userHost: user.host, - channelId: data.channel ? data.channel.id : null, + channelId: data.channel?.id ?? null, }); if (!oldnote.hasPoll) { @@ -577,12 +574,15 @@ export class NoteEditService implements OnApplicationShutdown { await this.notesRepository.update(oldnote.id, note); } + // Re-fetch note to get the default values of null / unset fields. + const edited = await this.notesRepository.findOneByOrFail({ id: note.id }); + setImmediate('post edited', { signal: this.#shutdownController.signal }).then( - () => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!), + () => this.postNoteEdited(edited, oldnote, user, data, silent, tags!, mentionedUsers!), () => { /* aborted, ignore this */ }, ); - return note; + return edited; } else { return oldnote; } @@ -610,6 +610,8 @@ export class NoteEditService implements OnApplicationShutdown { } } + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); + // ハッシュタグ更新 this.pushToTl(note, user); @@ -628,44 +630,12 @@ export class NoteEditService implements OnApplicationShutdown { 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 }); - 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.globalEventService.publishNoteStream(note.id, 'updated', { + cw: note.cw, + text: note.text ?? '', + }); this.roleService.addNoteToRoleTimeline(noteObj); @@ -673,8 +643,6 @@ export class NoteEditService implements OnApplicationShutdown { 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) { // 通知 @@ -697,7 +665,6 @@ export class NoteEditService implements OnApplicationShutdown { if (!isThreadMuted && !muted) { nm.push(data.reply.userId, 'edited'); this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj); - this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj }); } } @@ -707,8 +674,8 @@ export class NoteEditService implements OnApplicationShutdown { //#region AP deliver if (!data.localOnly && this.userEntityService.isLocalUser(user)) { - (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user); + trackTask(async () => { + const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote }); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); // メンションされたリモートユーザーに配送 @@ -751,12 +718,12 @@ export class NoteEditService implements OnApplicationShutdown { } } - if (['public'].includes(note.visibility)) { - this.relayService.deliverToRelays(user, noteActivity); - } + await dm.execute(); - trackPromise(dm.execute()); - })(); + if (['public'].includes(note.visibility)) { + await this.relayService.deliverToRelays(user, noteActivity); + } + }); } //#endregion } @@ -803,52 +770,6 @@ export class NoteEditService implements OnApplicationShutdown { (note.files != null && note.files.length > 0); } - // TODO why is this unused? - @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.exists({ - where: { - userId: u.id, - threadId: note.threadId ?? note.id, - }, - }); - - const [ - userIdsWhoMeMuting, - ] = u.id ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(u.id), - ]) : [new Set()]; - - const muted = isUserRelated(note, userIdsWhoMeMuting); - - if (isThreadMuted || muted) { - continue; - } - - const detailPackedNote = await this.noteEntityService.pack(note, u, { - detail: true, - }); - - this.globalEventService.publishMainStream(u.id, 'edited', detailPackedNote); - this.webhookService.enqueueUserWebhook(u.id, 'edited', { note: detailPackedNote }); - - // Create notification - nm.push(u.id, 'edited'); - } - } - - @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) { - if (data.localOnly) return null; - - const content = this.isRenote(data) && !this.isQuote(data) - ? 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, user, false), user); - - return this.apRendererService.addContext(content); - } - @bindThis private index(note: MiNote) { if (note.text == null && note.cw == null) return; @@ -901,14 +822,7 @@ export class NoteEditService implements OnApplicationShutdown { // 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.cacheService.getNonHibernatedFollowers(user.id), this.userListMembershipsRepository.find({ where: { userId: user.id, @@ -924,6 +838,7 @@ export class NoteEditService implements OnApplicationShutdown { // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする for (const following of followings) { + if (following.followerHost !== null) continue; // 基本的にvisibleUserIdsには自身のidが含まれている前提であること if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; @@ -1025,17 +940,19 @@ export class NoteEditService implements OnApplicationShutdown { }); 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, - }); + await Promise.all([ + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }), + this.followingsRepository.update({ + followerId: In(hibernatedUsers.map(x => x.id)), + }, { + isFollowerHibernated: true, + }), + this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])), + ]); } } diff --git a/packages/backend/src/core/NotePiningService.ts b/packages/backend/src/core/NotePiningService.ts index d38b48b65d..a678108189 100644 --- a/packages/backend/src/core/NotePiningService.ts +++ b/packages/backend/src/core/NotePiningService.ts @@ -10,7 +10,7 @@ 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 { MiUserNotePining } from '@/models/UserNotePining.js'; import { RelayService } from '@/core/RelayService.js'; import type { Config } from '@/config.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -18,6 +18,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import type { DataSource } from 'typeorm'; @Injectable() export class NotePiningService { @@ -34,6 +35,9 @@ export class NotePiningService { @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, + @Inject(DI.db) + private readonly db: DataSource, + private userEntityService: UserEntityService, private idService: IdService, private roleService: RoleService, @@ -49,7 +53,7 @@ export class NotePiningService { * @param noteId */ @bindThis - public async addPinned(user: { id: MiUser['id']; host: MiUser['host']; }, noteId: MiNote['id']) { + public async addPinned(user: MiUser, noteId: MiNote['id']) { // Fetch pinee const note = await this.notesRepository.findOneBy({ id: noteId, @@ -57,28 +61,30 @@ export class NotePiningService { }); if (note == null) { - throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.'); + throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', `Note ${noteId} does not exist`); } - const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id }); + await this.db.transaction(async tem => { + const pinings = await tem.findBy(MiUserNotePining, { 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.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.'); - } + 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); + await tem.insert(MiUserNotePining, { + id: this.idService.gen(), + 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, note.id, true); + this.deliverPinnedChange(user, note.id, true); } } @@ -88,7 +94,7 @@ export class NotePiningService { * @param noteId */ @bindThis - public async removePinned(user: { id: MiUser['id']; host: MiUser['host']; }, noteId: MiNote['id']) { + public async removePinned(user: MiUser, noteId: MiNote['id']) { // Fetch unpinee const note = await this.notesRepository.findOneBy({ id: noteId, @@ -96,7 +102,7 @@ export class NotePiningService { }); if (note == null) { - throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.'); + throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', `Note ${noteId} does not exist`); } this.userNotePiningsRepository.delete({ @@ -106,22 +112,19 @@ export class NotePiningService { // Deliver to remote followers if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) { - this.deliverPinnedChange(user.id, noteId, false); + this.deliverPinnedChange(user, 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'); - + public async deliverPinnedChange(user: MiUser, noteId: MiNote['id'], isAddition: boolean) { 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); + await this.apDeliverManagerService.deliverToFollowers(user, content); + await 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 320b23cc1a..0000000000 --- a/packages/backend/src/core/NoteReadService.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * 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'; -import { trackPromise } from '@/misc/promise-tracker.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.exists({ - 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, - }; - - /* we may be called from NoteEditService, for a note that's - already present in the `note_unread` table: `upsert` makes sure - we don't throw a "duplicate key" error, while still updating - the other columns if they've changed */ - await this.noteUnreadsRepository.upsert(unread, ['userId', 'noteId']); - - // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { - const exist = await this.noteUnreadsRepository.exists({ 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 { - if (notes.length === 0) return; - - const noteIds = new Set(); - - for (const note of notes) { - if (note.mentions && note.mentions.includes(userId)) { - noteIds.add(note.id); - } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { - noteIds.add(note.id); - } - } - - if (noteIds.size === 0) return; - - // Remove the record - await this.noteUnreadsRepository.delete({ - userId: userId, - noteId: In(Array.from(noteIds)), - }); - - // TODO: ↓まとめてクエリしたい - - trackPromise(this.noteUnreadsRepository.countBy({ - userId: userId, - isMentioned: true, - }).then(mentionsCount => { - if (mentionsCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions'); - } - })); - - trackPromise(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 index 68ad92f396..2ce7bdb5a9 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; +import { ReplyError } from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; @@ -19,7 +20,7 @@ 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'; +import { FilterUnionByProperty, groupedNotificationTypes, obsoleteNotificationTypes } from '@/types.js'; import { trackPromise } from '@/misc/promise-tracker.js'; @Injectable() @@ -112,27 +113,27 @@ export class NotificationService implements OnApplicationShutdown { } if (recieveConfig?.type === 'following') { - const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)); if (!isFollowing) { return null; } } else if (recieveConfig?.type === 'follower') { - const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)); + const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(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)), + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)), ]); if (!(isFollowing && isFollower)) { return null; } } else if (recieveConfig?.type === 'followingOrFollower') { 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)), + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)), ]); if (!isFollowing && !isFollower) { return null; @@ -145,21 +146,36 @@ export class NotificationService implements OnApplicationShutdown { } } - const notification = { - id: this.idService.gen(), - createdAt: new Date(), - type: type, - ...(notifierId ? { - notifierId, - } : {}), - ...data, - } as any as FilterUnionByProperty; + const createdAt = new Date(); + let notification: FilterUnionByProperty; + let redisId: string; - const redisIdPromise = this.redisClient.xadd( - `notificationTimeline:${notifieeId}`, - 'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(), - '*', - 'data', JSON.stringify(notification)); + do { + notification = { + id: this.idService.gen(), + createdAt, + type: type, + ...(notifierId ? { + notifierId, + } : {}), + ...data, + } as unknown as FilterUnionByProperty; + + try { + redisId = (await this.redisClient.xadd( + `notificationTimeline:${notifieeId}`, + 'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(), + this.toXListId(notification.id, 0), + 'data', JSON.stringify(notification)))!; + } catch (e) { + // The ID specified in XADD is equal or smaller than the target stream top item で失敗することがあるのでリトライ + if (e instanceof ReplyError) continue; + throw e; + } + + break; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } while (true); const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); @@ -173,7 +189,7 @@ export class NotificationService implements OnApplicationShutdown { 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; + if (latestReadNotificationId && (latestReadNotificationId >= redisId)) return; this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); @@ -228,6 +244,79 @@ export class NotificationService implements OnApplicationShutdown { this.#shutdownController.abort(); } + private toXListId(id: string, offset: number): string { + const { date, additional } = this.idService.parseFull(id); + return (date + offset).toString() + '-' + additional.toString(); + } + + @bindThis + public async getNotifications( + userId: MiUser['id'], + { + sinceId, + untilId, + limit = 20, + includeTypes, + excludeTypes, + }: { + sinceId?: string, + untilId?: string, + limit?: number, + // any extra types are allowed, those are no-op + includeTypes?: (MiNotification['type'] | string)[], + excludeTypes?: (MiNotification['type'] | string)[], + }, + ): Promise { + let sinceTime = sinceId ? this.toXListId(sinceId, 1) : null; + let untilTime = untilId ? this.toXListId(untilId, -1) : null; + + let notifications: MiNotification[]; + for (;;) { + let notificationsRes: [id: string, fields: string[]][]; + + // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 + if (sinceTime && !untilTime) { + notificationsRes = await this.redisClient.xrange( + `notificationTimeline:${userId}`, + '(' + sinceTime, + '+', + 'COUNT', limit); + } else { + notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + untilTime ? '(' + untilTime : '+', + sinceTime ? '(' + sinceTime : '-', + 'COUNT', limit); + } + + if (notificationsRes.length === 0) { + return []; + } + + notifications = notificationsRes.map(x => JSON.parse(x[1][1])) 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) { + // 通知が1件以上ある場合は返す + break; + } + + // フィルタしたことで通知が0件になった場合、次のページを取得する + if (sinceId && !untilId) { + sinceTime = notificationsRes[notificationsRes.length - 1][0]; + } else { + untilTime = notificationsRes[notificationsRes.length - 1][0]; + } + } + + return notifications; + } + @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 index d6364613bd..33262a4804 100644 --- a/packages/backend/src/core/PollService.ts +++ b/packages/backend/src/core/PollService.ts @@ -90,10 +90,7 @@ export class PollService { } @bindThis - public async deliverQuestionUpdate(noteId: MiNote['id']) { - const note = await this.notesRepository.findOneBy({ id: noteId }); - if (note == null) throw new Error('note not found'); - + public async deliverQuestionUpdate(note: MiNote) { if (note.localOnly) return; const user = await this.usersRepository.findOneBy({ id: note.userId }); @@ -101,8 +98,8 @@ export class PollService { if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, user, false), user)); - this.apDeliverManagerService.deliverToFollowers(user, content); - this.relayService.deliverToRelays(user, content); + await this.apDeliverManagerService.deliverToFollowers(user, content); + await 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 c3ff2a68d3..0000000000 --- a/packages/backend/src/core/ProxyAccountService.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { MiMeta, UsersRepository } from '@/models/_.js'; -import type { MiLocalUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class ProxyAccountService { - constructor( - @Inject(DI.meta) - private meta: MiMeta, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - ) { - } - - @bindThis - public async fetch(): Promise { - if (this.meta.proxyAccountId == null) return null; - return await this.usersRepository.findOneByOrFail({ id: this.meta.proxyAccountId }) as MiLocalUser; - } -} diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 1479bb00d9..e3f10d4504 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -12,7 +12,8 @@ import type { Packed } from '@/misc/json-schema.js'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { RedisKVCache } from '@/misc/cache.js'; +import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; // Defined also packages/sw/types.ts#L13 type PushNotificationsTypes = { @@ -22,6 +23,7 @@ type PushNotificationsTypes = { note: Packed<'Note'>; }; 'readAllNotifications': undefined; + newChatMessage: Packed<'ChatMessage'>; }; // Reduce length because push message servers have character limits @@ -47,7 +49,7 @@ function truncateBody(type: T, body: Pus @Injectable() export class PushNotificationService implements OnApplicationShutdown { - private subscriptionsCache: RedisKVCache; + private subscriptionsCache: QuantumKVCache; constructor( @Inject(DI.config) @@ -61,13 +63,11 @@ export class PushNotificationService implements OnApplicationShutdown { @Inject(DI.swSubscriptionsRepository) private swSubscriptionsRepository: SwSubscriptionsRepository, + private readonly internalEventService: InternalEventService, ) { - this.subscriptionsCache = new RedisKVCache(this.redisClient, 'userSwSubscriptions', { + this.subscriptionsCache = new QuantumKVCache(this.internalEventService, '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), }); } @@ -113,8 +113,8 @@ export class PushNotificationService implements OnApplicationShutdown { endpoint: subscription.endpoint, auth: subscription.auth, publickey: subscription.publickey, - }).then(() => { - this.refreshCache(userId); + }).then(async () => { + await this.refreshCache(userId); }); } }); @@ -122,8 +122,8 @@ export class PushNotificationService implements OnApplicationShutdown { } @bindThis - public refreshCache(userId: string): void { - this.subscriptionsCache.refresh(userId); + public async refreshCache(userId: string): Promise { + await this.subscriptionsCache.refresh(userId); } @bindThis diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index c4feeaf971..d0e281e20c 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -4,13 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets, ObjectLiteral } from 'typeorm'; +import { Brackets, Not, WhereExpressionBuilder } 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 { MiInstance } from '@/models/Instance.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import type { SelectQueryBuilder } from 'typeorm'; +import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm'; @Injectable() export class QueryService { @@ -36,6 +37,12 @@ export class QueryService { @Inject(DI.renoteMutingsRepository) private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.instancesRepository) + private readonly instancesRepository: InstancesRepository, + + @Inject(DI.meta) + private meta: MiMeta, + private idService: IdService, ) { } @@ -69,186 +76,485 @@ export class QueryService { // ここでいう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 }); - + public generateBlockedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { // 投稿の作者にブロックされていない かつ // 投稿の返信先の作者にブロックされていない かつ // 投稿の引用元の作者にブロックされていない - 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()); + return this + .andNotBlockingUser(q, 'note.userId', ':meId') + .andWhere(new Brackets(qb => this + .orNotBlockingUser(qb, 'note.replyUserId', ':meId') + .orWhere('note.replyUserId IS NULL'))) + .andWhere(new Brackets(qb => this + .orNotBlockingUser(qb, 'note.renoteUserId', ':meId') + .orWhere('note.renoteUserId IS NULL'))) + .setParameters({ meId: me.id }); } @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()); + public generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + this.andNotBlockingUser(q, ':meId', 'user.id'); + this.andNotBlockingUser(q, 'user.id', ':meId'); + return q.setParameters({ meId: me.id }); } @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()); + public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + return this + .andNotMutingThread(q, ':meId', 'note.id') + .andWhere(new Brackets(qb => this + .orNotMutingThread(qb, ':meId', 'note.threadId') + .orWhere('note.threadId IS NULL'))) + .setParameters({ meId: me.id }); } @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 }); - + public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder { // 投稿の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない - 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() })`); - })) + return this + .andNotMutingUser(q, ':meId', 'note.userId', exclude) + .andWhere(new Brackets(qb => this + .orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude) + .orWhere('note.replyUserId IS NULL'))) + .andWhere(new Brackets(qb => this + .orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude) + .orWhere('note.renoteUserId IS NULL'))) + // TODO exclude should also pass a host to skip these instances // 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()); + .andWhere(new Brackets(qb => this + .andNotMutingInstance(qb, ':meId', 'note.userHost') + .orWhere('note.userHost IS NULL'))) + .andWhere(new Brackets(qb => this + .orNotMutingInstance(qb, ':meId', 'note.replyUserHost') + .orWhere('note.replyUserHost IS NULL'))) + .andWhere(new Brackets(qb => this + .orNotMutingInstance(qb, ':meId', 'note.renoteUserHost') + .orWhere('note.renoteUserHost IS NULL'))) + .setParameters({ meId: me.id }); } @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()); + public generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + return this + .andNotMutingUser(q, ':meId', 'user.id') + .setParameters({ meId: me.id }); } + // This intentionally skips isSuspended, isDeleted, makeNotesFollowersOnlyBefore, makeNotesHiddenBefore, and requireSigninToViewContents. + // NoteEntityService checks these automatically and calls hideNote() to hide them without breaking threads. + // For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user. @bindThis - public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): void { + public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): SelectQueryBuilder { // 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'); + return q.andWhere(new Brackets(qb => { + // Public post + qb.orWhere('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); - q.andWhere(new Brackets(qb => { + if (me != null) { qb - // 公開投稿である - .where(new Brackets(qb => { - qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })) - // または 自分自身 - .orWhere('note.userId = :meId') - // または 自分宛て + // My post + .orWhere(':meId = note.userId') + // Visible to me .orWhere(':meIdAsList <@ note.visibleUserIds') - .orWhere(':meIdAsList <@ 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'); - })); - })); - })); + // Followers-only post + .orWhere(new Brackets(qb => qb + .andWhere(new Brackets(qbb => this + // Following author + .orFollowingUser(qbb, ':meId', 'note.userId') + // Mentions me + .orWhere(':meIdAsList <@ note.mentions') + // Reply to me + .orWhere(':meId = note.replyUserId'))) + .andWhere('note.visibility = \'followers\''))); - q.setParameters({ meId: me.id, meIdAsList: [me.id] }); - } + q.setParameters({ meId: me.id, meIdAsList: [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() })`); - })) + public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + return q + .andWhere(new Brackets(qb => this + .orNotMutingRenote(qb, ':meId', 'note.userId') .orWhere('note.renoteId IS NULL') - .orWhere('note.text IS NOT NULL'); - })); + .orWhere('note.text IS NOT NULL') + .orWhere('note.cw IS NOT NULL') + .orWhere('note.replyId IS NOT NULL') + .orWhere('note.hasPoll = true') + .orWhere('note.fileIds != \'{}\''))) + .setParameters({ meId: me.id }); + } - q.setParameters(mutingQuery.getParameters()); + @bindThis + public generateExcludedRenotesQueryForNotes(q: Q): Q { + return this.andIsNotRenote(q, 'note'); + } + + @bindThis + public generateBlockedHostQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): SelectQueryBuilder { + const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this + .leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`) + .andWhere(new Brackets(qb => { + qb + .orWhere(`"${key}Instance" IS NULL`) // local + .orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked + + if (excludeAuthor) { + qb.orWhere(`note.userId = note.${key}Id`); // author + } + })); + + if (!excludeAuthor) { + checkFor('user'); + } + checkFor('replyUser'); + checkFor('renoteUser'); + + return q; + } + + @bindThis + public generateSilencedUserQueryForNotes(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): SelectQueryBuilder { + if (!me) { + return q.andWhere('user.isSilenced = false'); + } + + return this + .leftJoinInstance(q, 'note.userInstance', 'userInstance') + .andWhere(new Brackets(qb => this + // case 1: we are following the user + .orFollowingUser(qb, ':meId', 'note.userId') + // case 2: user not silenced AND instance not silenced + .orWhere(new Brackets(qbb => qbb + .andWhere(new Brackets(qbbb => qbbb + .orWhere('"userInstance"."isSilenced" = false') + .orWhere('"userInstance" IS NULL'))) + .andWhere('user.isSilenced = false'))))) + .setParameters({ meId: me.id }); + } + + /** + * Left-joins an instance in to the query with a given alias and optional condition. + * These calls are de-duplicated - multiple uses of the same alias are skipped. + */ + @bindThis + public leftJoinInstance(q: SelectQueryBuilder, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder { + // Skip if it's already joined, otherwise we'll get an error + if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) { + q.leftJoin(relation, alias, condition); + } + + return q; + } + + /** + * Adds OR condition that noteProp (note ID) refers to a quote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public orIsQuote(q: Q, noteProp: string): Q { + return this.addIsQuote(q, noteProp, 'orWhere'); + } + + /** + * Adds AND condition that noteProp (note ID) refers to a quote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public andIsQuote(q: Q, noteProp: string): Q { + return this.addIsQuote(q, noteProp, 'andWhere'); + } + + private addIsQuote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { + return q[join](new Brackets(qb => qb + .andWhere(`${noteProp}.renoteId IS NOT NULL`) + .andWhere(new Brackets(qbb => qbb + .orWhere(`${noteProp}.text IS NOT NULL`) + .orWhere(`${noteProp}.cw IS NOT NULL`) + .orWhere(`${noteProp}.replyId IS NOT NULL`) + .orWhere(`${noteProp}.hasPoll = true`) + .orWhere(`${noteProp}.fileIds != '{}'`))))); + } + + /** + * Adds OR condition that noteProp (note ID) does not refer to a quote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public orIsNotQuote(q: Q, noteProp: string): Q { + return this.addIsNotQuote(q, noteProp, 'orWhere'); + } + + /** + * Adds AND condition that noteProp (note ID) does not refer to a quote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public andIsNotQuote(q: Q, noteProp: string): Q { + return this.addIsNotQuote(q, noteProp, 'andWhere'); + } + + private addIsNotQuote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { + return q[join](new Brackets(qb => qb + .orWhere(`${noteProp}.renoteId IS NULL`) + .orWhere(new Brackets(qb => qb + .andWhere(`${noteProp}.text IS NULL`) + .andWhere(`${noteProp}.cw IS NULL`) + .andWhere(`${noteProp}.replyId IS NULL`) + .andWhere(`${noteProp}.hasPoll = false`) + .andWhere(`${noteProp}.fileIds = '{}'`))))); + } + + /** + * Adds OR condition that noteProp (note ID) refers to a renote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public orIsRenote(q: Q, noteProp: string): Q { + return this.addIsRenote(q, noteProp, 'orWhere'); + } + + /** + * Adds AND condition that noteProp (note ID) refers to a renote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public andIsRenote(q: Q, noteProp: string): Q { + return this.addIsRenote(q, noteProp, 'andWhere'); + } + + private addIsRenote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { + return q[join](new Brackets(qb => qb + .andWhere(`${noteProp}.renoteId IS NOT NULL`) + .andWhere(`${noteProp}.text IS NULL`) + .andWhere(`${noteProp}.cw IS NULL`) + .andWhere(`${noteProp}.replyId IS NULL`) + .andWhere(`${noteProp}.hasPoll = false`) + .andWhere(`${noteProp}.fileIds = '{}'`))); + } + + /** + * Adds OR condition that noteProp (note ID) does not refer to a renote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public orIsNotRenote(q: Q, noteProp: string): Q { + return this.addIsNotRenote(q, noteProp, 'orWhere'); + } + + /** + * Adds AND condition that noteProp (note ID) does not refer to a renote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public andIsNotRenote(q: Q, noteProp: string): Q { + return this.addIsNotRenote(q, noteProp, 'andWhere'); + } + + private addIsNotRenote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { + return q[join](new Brackets(qb => qb + .orWhere(`${noteProp}.renoteId IS NULL`) + .orWhere(`${noteProp}.text IS NOT NULL`) + .orWhere(`${noteProp}.cw IS NOT NULL`) + .orWhere(`${noteProp}.replyId IS NOT NULL`) + .orWhere(`${noteProp}.hasPoll = true`) + .orWhere(`${noteProp}.fileIds != '{}'`))); + } + + /** + * Adds OR condition that followerProp (user ID) is following followeeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public orFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { + return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere'); + } + + /** + * Adds AND condition that followerProp (user ID) is following followeeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { + return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere'); + } + + private addFollowingUser(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('1') + .andWhere(`following.followerId = ${followerProp}`) + .andWhere(`following.followeeId = ${followeeProp}`); + + return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters()); + }; + + /** + * Adds OR condition that followerProp (user ID) is following followeeProp (channel ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public orFollowingChannel(q: Q, followerProp: string, followeeProp: string): Q { + return this.addFollowingChannel(q, followerProp, followeeProp, 'orWhere'); + } + + /** + * Adds AND condition that followerProp (user ID) is following followeeProp (channel ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andFollowingChannel(q: Q, followerProp: string, followeeProp: string): Q { + return this.addFollowingChannel(q, followerProp, followeeProp, 'andWhere'); + } + + private addFollowingChannel(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q { + const followingQuery = this.channelFollowingsRepository.createQueryBuilder('following') + .select('1') + .andWhere(`following.followerId = ${followerProp}`) + .andWhere(`following.followeeId = ${followeeProp}`); + + return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters()); + } + + /** + * Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public orNotBlockingUser(q: Q, blockerProp: string, blockeeProp: string): Q { + return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'orWhere'); + } + + /** + * Adds AND condition that blockerProp (user ID) is not blocking blockeeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotBlockingUser(q: Q, blockerProp: string, blockeeProp: string): Q { + return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere'); + } + + private excludeBlockingUser(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q { + const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') + .select('1') + .andWhere(`blocking.blockerId = ${blockerProp}`) + .andWhere(`blocking.blockeeId = ${blockeeProp}`); + + return q[join](`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters()); + }; + + /** + * Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public orNotMutingUser(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q { + return this.excludeMutingUser(q, muterProp, muteeProp, 'orWhere', exclude); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting muteeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingUser(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q { + return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude); + } + + private excludeMutingUser(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere', exclude?: { id: MiUser['id'] }): Q { + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('1') + .andWhere(`muting.muterId = ${muterProp}`) + .andWhere(`muting.muteeId = ${muteeProp}`); + + if (exclude) { + mutingQuery.andWhere({ muteeId: Not(exclude.id) }); + } + + return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); + } + + /** + * Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public orNotMutingRenote(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingRenote(q, muterProp, muteeProp, 'orWhere'); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingRenote(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere'); + } + + private excludeMutingRenote(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { + const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') + .select('1') + .andWhere(`renote_muting.muterId = ${muterProp}`) + .andWhere(`renote_muting.muteeId = ${muteeProp}`); + + return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); + }; + + /** + * Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host). + * Both props should be expressions, not raw values. + */ + @bindThis + public orNotMutingInstance(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingInstance(q, muterProp, muteeProp, 'orWhere'); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting muteeProp (instance host). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingInstance(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere'); + } + + private excludeMutingInstance(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { + const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') + .select('1') + .andWhere(`user_profile.userId = ${muterProp}`) + .andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`); + + return q[join](`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters()); + } + + /** + * Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public orNotMutingThread(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingThread(q, muterProp, muteeProp, 'orWhere'); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingThread(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere'); + } + + private excludeMutingThread(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { + const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') + .select('1') + .andWhere(`threadMuted.userId = ${muterProp}`) + .andWhere(`threadMuted.threadId = ${muteeProp}`); + + return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters()); } } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 039c47724b..361e636662 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; +import { MetricsTime, type JobType } from 'bullmq'; +import { parse as parseRedisInfo } from 'redis-info'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; @@ -40,6 +42,19 @@ import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; import { MiNote } from '@/models/Note.js'; +export const QUEUE_TYPES = [ + 'system', + 'endedPollNotification', + 'deliver', + 'inbox', + 'db', + 'relationship', + 'objectStorage', + 'userWebhookDeliver', + 'systemWebhookDeliver', + 'scheduleNotePost', +] as const; + @Injectable() export class QueueService { constructor( @@ -60,50 +75,58 @@ export class QueueService { this.systemQueue.add('tickCharts', { }, { repeat: { pattern: '55 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('resyncCharts', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('cleanCharts', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('aggregateRetention', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('clean', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('checkExpiredMutings', { }, { repeat: { pattern: '*/5 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('bakeBufferedReactions', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('checkModeratorsActivity', { }, { // 毎時30分に起動 repeat: { pattern: '30 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); } @@ -125,13 +148,21 @@ export class QueueService { isSharedInbox, }; - return this.deliverQueue.add(to, data, { + const label = to.replace('https://', '').replace('/inbox', ''); + + return this.deliverQueue.add(label, data, { attempts: this.config.deliverJobMaxAttempts ?? 12, backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -153,12 +184,18 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }; await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({ - name: d[0], + name: d[0].replace('https://', '').replace('/inbox', ''), data: { user, content: contentBody, @@ -179,13 +216,21 @@ export class QueueService { signature, }; - return this.inboxQueue.add('', data, { + const label = (activity.id ?? '').replace('https://', '').replace('/activity', ''); + + return this.inboxQueue.add(label, data, { attempts: this.config.inboxJobMaxAttempts ?? 8, backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -194,8 +239,14 @@ export class QueueService { return this.dbQueue.add('deleteDriveFiles', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -204,8 +255,14 @@ export class QueueService { return this.dbQueue.add('exportCustomEmojis', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -224,8 +281,14 @@ export class QueueService { return this.dbQueue.add('exportNotes', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -234,8 +297,14 @@ export class QueueService { return this.dbQueue.add('exportClips', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -244,8 +313,14 @@ export class QueueService { return this.dbQueue.add('exportFavorites', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -256,8 +331,14 @@ export class QueueService { excludeMuting, excludeInactive, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -266,8 +347,14 @@ export class QueueService { return this.dbQueue.add('exportMuting', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -276,8 +363,14 @@ export class QueueService { return this.dbQueue.add('exportBlocking', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -286,8 +379,14 @@ export class QueueService { return this.dbQueue.add('exportUserLists', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -296,8 +395,14 @@ export class QueueService { return this.dbQueue.add('exportAntennas', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -308,8 +413,14 @@ export class QueueService { fileId: fileId, withReplies, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -373,8 +484,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -384,8 +501,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -405,8 +528,14 @@ export class QueueService { name, data, opts: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }, }; } @@ -417,8 +546,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -428,8 +563,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -439,8 +580,14 @@ export class QueueService { user: { id: user.id }, antenna, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -450,8 +597,14 @@ export class QueueService { user: { id: user.id }, soft: opts.soft, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -501,8 +654,14 @@ export class QueueService { withReplies: data.withReplies, }, opts: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, ...opts, }, }; @@ -513,16 +672,28 @@ export class QueueService { return this.objectStorageQueue.add('deleteFile', { key: key, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @bindThis public createCleanRemoteFilesJob() { return this.objectStorageQueue.add('cleanRemoteFiles', {}, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -553,8 +724,14 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -584,21 +761,202 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @bindThis - public destroy() { - this.deliverQueue.once('cleaned', (jobs, status) => { - //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); - }); - this.deliverQueue.clean(0, 0, 'delayed'); + private getQueue(type: typeof QUEUE_TYPES[number]): Bull.Queue { + switch (type) { + case 'system': return this.systemQueue; + case 'endedPollNotification': return this.endedPollNotificationQueue; + case 'deliver': return this.deliverQueue; + case 'inbox': return this.inboxQueue; + case 'db': return this.dbQueue; + case 'relationship': return this.relationshipQueue; + case 'objectStorage': return this.objectStorageQueue; + case 'userWebhookDeliver': return this.userWebhookDeliverQueue; + case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue; + case 'scheduleNotePost': return this.ScheduleNotePostQueue; + default: throw new Error(`Unrecognized queue type: ${type}`); + } + } - this.inboxQueue.once('cleaned', (jobs, status) => { - //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + @bindThis + public async queueClear(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') { + const queue = this.getQueue(queueType); + + if (state === '*') { + await Promise.all([ + queue.clean(0, 0, 'completed'), + queue.clean(0, 0, 'wait'), + queue.clean(0, 0, 'active'), + queue.clean(0, 0, 'paused'), + queue.clean(0, 0, 'prioritized'), + queue.clean(0, 0, 'delayed'), + queue.clean(0, 0, 'failed'), + ]); + } else { + await queue.clean(0, 0, state); + } + } + + @bindThis + public async queuePromoteJobs(queueType: typeof QUEUE_TYPES[number]) { + const queue = this.getQueue(queueType); + await queue.promoteJobs(); + } + + @bindThis + public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + if (job.finishedOn != null) { + await job.retry(); + } else { + await job.promote(); + } + } + } + + @bindThis + public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + await job.remove(); + } + } + + @bindThis + private packJobData(job: Bull.Job) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : []; + stacktrace.reverse(); + + return { + id: job.id, + name: job.name, + data: job.data, + opts: job.opts, + timestamp: job.timestamp, + processedOn: job.processedOn, + processedBy: job.processedBy, + finishedOn: job.finishedOn, + progress: job.progress, + attempts: job.attemptsMade, + delay: job.delay, + failedReason: job.failedReason, + stacktrace: stacktrace, + returnValue: job.returnvalue, + isFailed: !!job.failedReason || (Array.isArray(stacktrace) && stacktrace.length > 0), + }; + } + + @bindThis + public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + return this.packJobData(job); + } else { + throw new Error(`Job not found: ${jobId}`); + } + } + + @bindThis + public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) { + const RETURN_LIMIT = 100; + const queue = this.getQueue(queueType); + let jobs: Bull.Job[]; + + if (search) { + jobs = await queue.getJobs(jobTypes, 0, 1000); + + jobs = jobs.filter(job => { + const jobString = JSON.stringify(job).toLowerCase(); + return search.toLowerCase().split(' ').every(term => { + return jobString.includes(term); + }); + }); + + jobs = jobs.slice(0, RETURN_LIMIT); + } else { + jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT); + } + + return jobs.map(job => this.packJobData(job)); + } + + @bindThis + public async queueGetQueues() { + const fetchings = QUEUE_TYPES.map(async type => { + const queue = this.getQueue(type); + + const counts = await queue.getJobCounts(); + const isPaused = await queue.isPaused(); + const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); + const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); + + return { + name: type, + counts: counts, + isPaused, + metrics: { + completed: metrics_completed, + failed: metrics_failed, + }, + }; }); - this.inboxQueue.clean(0, 0, 'delayed'); + + return await Promise.all(fetchings); + } + + @bindThis + public async queueGetQueue(queueType: typeof QUEUE_TYPES[number]) { + const queue = this.getQueue(queueType); + const counts = await queue.getJobCounts(); + const isPaused = await queue.isPaused(); + const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); + const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); + const db = parseRedisInfo(await (await queue.client).info()); + + return { + name: queueType, + qualifiedName: queue.qualifiedName, + counts: counts, + isPaused, + metrics: { + completed: metrics_completed, + failed: metrics_failed, + }, + db: { + version: db.redis_version, + mode: db.redis_mode, + runId: db.run_id, + processId: db.process_id, + port: parseInt(db.tcp_port), + os: db.os, + uptime: parseInt(db.uptime_in_seconds), + memory: { + total: parseInt(db.total_system_memory) || parseInt(db.maxmemory), + used: parseInt(db.used_memory), + fragmentationRatio: parseInt(db.mem_fragmentation_ratio), + peak: parseInt(db.used_memory_peak), + }, + clients: { + connected: parseInt(db.connected_clients), + blocked: parseInt(db.blocked_clients), + }, + }, + }; } } diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 0179b0680f..8d2dc7d4e8 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -10,7 +10,7 @@ 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 { 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'; @@ -30,6 +30,8 @@ import { trackPromise } from '@/misc/promise-tracker.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; +import { CacheService } from '@/core/CacheService.js'; +import type { DataSource } from 'typeorm'; const FALLBACK = '\u2764'; @@ -88,6 +90,9 @@ export class ReactionService { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.db) + private readonly db: DataSource, + private utilityService: UtilityService, private customEmojiService: CustomEmojiService, private roleService: RoleService, @@ -102,6 +107,7 @@ export class ReactionService { private apDeliverManagerService: ApDeliverManagerService, private notificationService: NotificationService, private perUserReactionsChart: PerUserReactionsChart, + private readonly cacheService: CacheService, ) { } @@ -111,12 +117,12 @@ export class ReactionService { 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'); + throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7', 'Note not accessible for you.'); } } // check visibility - if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { + if (!await this.noteEntityService.isVisibleForMe(note, user.id, { me: user })) { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } @@ -174,26 +180,28 @@ export class ReactionService { reaction, }; - try { - await this.noteReactionsRepository.insert(record); - } catch (e) { - if (isDuplicateKeyValueError(e)) { - const exists = await this.noteReactionsRepository.findOneByOrFail({ - noteId: note.id, - userId: user.id, - }); + const result = await this.db.transaction(async tem => { + await tem.createQueryBuilder(MiNoteReaction, 'noteReaction') + .insert() + .values(record) + .orIgnore() + .execute(); - 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; + return await tem.createQueryBuilder(MiNoteReaction, 'noteReaction') + .select() + .where({ noteId: note.id, userId: user.id }) + .getOneOrFail(); + }); + + if (result.id !== record.id) { + // Conflict with the same ID => nothing to do. + if (result.reaction === record.reaction) { + return; } + + // 別のリアクションがすでにされていたら置き換える + await this.delete(user, note); + await this.noteReactionsRepository.insert(record); } // Increment reactions count @@ -212,20 +220,28 @@ export class ReactionService { .execute(); } + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); + // 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 author = await this.cacheService.findUserById(note.userId); + if (author.isExplorable) { + const policies = await this.roleService.getUserPolicies(author); + if (policies.canTrend) { + if (note.channelId != null) { + if (note.replyId == null) { + this.featuredService.updateInChannelNotesRanking(note.channelId, note, 1); + } + } else { + if (note.visibility === 'public' && note.userHost == null && note.replyId == null) { + this.featuredService.updateGlobalNotesRanking(note, 1); + this.featuredService.updatePerUserNotesRanking(note.userId, note, 1); + } + } } } } @@ -298,22 +314,22 @@ export class ReactionService { } @bindThis - public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) { + public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, exist?: MiNoteReaction | null) { // if already unreacted - const exist = await this.noteReactionsRepository.findOneBy({ + exist ??= await this.noteReactionsRepository.findOneBy({ noteId: note.id, userId: user.id, }); if (exist == null) { - throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'reaction does not exist'); } // Delete reaction const result = await this.noteReactionsRepository.delete(exist.id); if (result.affected !== 1) { - throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'reaction does not exist'); } // Decrement reactions count @@ -330,6 +346,8 @@ export class ReactionService { .execute(); } + this.usersRepository.update({ id: user.id }, { updatedAt: new Date() }); + this.globalEventService.publishNoteStream(note.id, 'unreacted', { reaction: this.decodeReaction(exist.reaction).reaction, userId: user.id, diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index db32114346..9120de1f9f 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -4,53 +4,34 @@ */ 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 type { MiUser } from '@/models/User.js'; +import type { RelaysRepository } 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; +import { SystemAccountService } from '@/core/SystemAccountService.js'; @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 systemAccountService: SystemAccountService, private apRendererService: ApRendererService, ) { this.relaysCache = new MemorySingleCache(1000 * 60 * 10); // 10m } - @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.insertOne({ @@ -59,8 +40,8 @@ export class RelayService { status: 'requesting', }); - const relayActor = await this.getRelayActor(); - const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); + const relayActor = await this.systemAccountService.fetch('relay'); + const follow = this.apRendererService.renderFollowRelay(relay, relayActor); const activity = this.apRendererService.addContext(follow); this.queueService.deliver(relayActor, activity, relay.inbox, false); @@ -77,7 +58,7 @@ export class RelayService { throw new Error('relay not found'); } - const relayActor = await this.getRelayActor(); + const relayActor = await this.systemAccountService.fetch('relay'); const follow = this.apRendererService.renderFollowRelay(relay, relayActor); const undo = this.apRendererService.renderUndo(follow, relayActor); const activity = this.apRendererService.addContext(undo); diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index 098b5e1706..4dbc9d6a36 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -3,7 +3,6 @@ * 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'; @@ -18,6 +17,7 @@ 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'; +import { renderInlineError } from '@/misc/render-inline-error.js'; @Injectable() export class RemoteUserResolveService { @@ -44,27 +44,13 @@ export class RemoteUserResolveService { 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; + return await this.usersRepository.findOneByOrFail({ usernameLower, host: IsNull() }) as MiLocalUser; } host = this.utilityService.toPuny(host); if (host === this.utilityService.toPuny(this.config.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; + return await this.usersRepository.findOneByOrFail({ usernameLower, host: IsNull() }) as MiLocalUser; } const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null; @@ -74,7 +60,7 @@ export class RemoteUserResolveService { if (user == null) { const self = await this.resolveSelf(acctLower); - if (self.href.startsWith(this.config.url)) { + if (this.utilityService.isUriLocal(self.href)) { const local = this.apDbResolverService.parseUri(self.href); if (local.local && local.type === 'users') { // the LR points to local @@ -82,7 +68,7 @@ export class RemoteUserResolveService { .getUserFromApId(self.href) .then((u) => { if (u == null) { - throw new Error('local user not found'); + throw new Error(`local user not found: ${self.href}`); } else { return u; } @@ -90,7 +76,7 @@ export class RemoteUserResolveService { } } - this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); + this.logger.info(`Fetching new remote user ${chalk.magenta(acctLower)} from ${self.href}`); return await this.apPersonService.createPerson(self.href); } @@ -101,18 +87,16 @@ export class RemoteUserResolveService { 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}`); + this.logger.warn(`Detected URI mismatch for ${acctLower}`); // validate uri - const uri = new URL(self.href); - if (uri.hostname !== host) { - throw new Error('Invalid uri'); + const uriHost = this.utilityService.extractDbHost(self.href); + if (uriHost !== host) { + throw new Error(`Failed to correct URI for ${acctLower}: new URI ${self.href} has different host from previous URI ${user.uri}`); } await this.usersRepository.update({ @@ -121,37 +105,28 @@ export class RemoteUserResolveService { }, { uri: self.href, }); - } else { - this.logger.info(`uri is fine: ${acctLower}`); } + this.logger.info(`Corrected URI for ${acctLower} from ${user.uri} to ${self.href}`); + 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; - } - }); + return await this.usersRepository.findOneByOrFail({ uri: self.href }) 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 }`); + this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${renderInlineError(err)}`); + throw new Error(`Failed to WebFinger for ${acctLower}: error thrown`, { cause: err }); }); 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'); + throw new Error(`Failed to WebFinger for ${acctLower}: self link not found`); } return self; } diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 8c0a8f6cc7..b57ab6d9cb 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -587,6 +587,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null, lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null, movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null, + instance: null, + userProfile: null, } : null, user2: parsed.user2 != null ? { ...parsed.user2, @@ -597,6 +599,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null, lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null, movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null, + instance: null, + userProfile: null, } : null, }; } else { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 0bae3af385..b250eeee21 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -20,6 +20,7 @@ import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; +import type { FollowStats } 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'; @@ -48,6 +49,7 @@ export type RolePolicies = { canUseTranslator: boolean; canHideAds: boolean; driveCapacityMb: number; + maxFileSizeMb: number; alwaysMarkNsfw: boolean; canUpdateBioMedia: boolean; pinLimit: number; @@ -66,6 +68,8 @@ export type RolePolicies = { canImportFollowing: boolean; canImportMuting: boolean; canImportUserLists: boolean; + chatAvailability: 'available' | 'readonly' | 'unavailable'; + canTrend: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -82,14 +86,15 @@ export const DEFAULT_POLICIES: RolePolicies = { canManageCustomEmojis: false, canManageAvatarDecorations: false, canSearchNotes: false, - canUseTranslator: true, + canUseTranslator: false, canHideAds: false, driveCapacityMb: 100, + maxFileSizeMb: 25, alwaysMarkNsfw: false, canUpdateBioMedia: true, pinLimit: 5, antennaLimit: 5, - wordMuteLimit: 200, + wordMuteLimit: 1000, webhookLimit: 3, clipLimit: 10, noteEachClipsLimit: 200, @@ -103,11 +108,12 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportFollowing: true, canImportMuting: true, canImportUserLists: true, + chatAvailability: 'available', + canTrend: true, }; @Injectable() export class RoleService implements OnApplicationShutdown, OnModuleInit { - private rootUserIdCache: MemorySingleCache; private rolesCache: MemorySingleCache; private roleAssignmentByUserIdCache: MemoryKVCache; private notificationService: NotificationService; @@ -143,9 +149,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private moderationLogService: ModerationLogService, private fanoutTimelineService: FanoutTimelineService, ) { - this.rootUserIdCache = new MemorySingleCache(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに this.rolesCache = new MemorySingleCache(1000 * 60 * 60); // 1h this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m + // TODO additional cache for final calculation? this.redisForSub.on('message', this.onMessage); } @@ -219,20 +225,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean { + private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue, followStats: FollowStats): boolean { try { switch (value.type) { // ~かつ~ case 'and': { - return value.values.every(v => this.evalCond(user, roles, v)); + return value.values.every(v => this.evalCond(user, roles, v, followStats)); } // ~または~ case 'or': { - return value.values.some(v => this.evalCond(user, roles, v)); + return value.values.some(v => this.evalCond(user, roles, v, followStats)); } // ~ではない case 'not': { - return !this.evalCond(user, roles, value.value); + return !this.evalCond(user, roles, value.value, followStats); } // マニュアルロールがアサインされている case 'roleAssignedTo': { @@ -246,6 +252,23 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { case 'isRemote': { return this.userEntityService.isRemoteUser(user); } + // User is from a specific instance + case 'isFromInstance': { + if (user.host == null) { + return false; + } + if (value.subdomains) { + const userHost = '.' + user.host.toLowerCase(); + const targetHost = '.' + value.host.toLowerCase(); + return userHost.endsWith(targetHost); + } else { + return user.host.toLowerCase() === value.host.toLowerCase(); + } + } + // Is the user from a local bubble instance + case 'fromBubbleInstance': { + return user.host != null && this.meta.bubbleInstances.includes(user.host); + } // サスペンド済みユーザである case 'isSuspended': { return user.isSuspended; @@ -290,6 +313,30 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { case 'followingMoreThanOrEq': { return user.followingCount >= value.value; } + case 'localFollowersLessThanOrEq': { + return followStats.localFollowers <= value.value; + } + case 'localFollowersMoreThanOrEq': { + return followStats.localFollowers >= value.value; + } + case 'localFollowingLessThanOrEq': { + return followStats.localFollowing <= value.value; + } + case 'localFollowingMoreThanOrEq': { + return followStats.localFollowing >= value.value; + } + case 'remoteFollowersLessThanOrEq': { + return followStats.remoteFollowers <= value.value; + } + case 'remoteFollowersMoreThanOrEq': { + return followStats.remoteFollowers >= value.value; + } + case 'remoteFollowingLessThanOrEq': { + return followStats.remoteFollowing <= value.value; + } + case 'remoteFollowingMoreThanOrEq': { + return followStats.remoteFollowing >= value.value; + } // ノート数が指定値以下 case 'notesLessThanOrEq': { return user.notesCount <= value.value; @@ -314,8 +361,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserAssigns(userId: MiUser['id']) { + public async getUserAssigns(userOrId: MiUser | MiUser['id']) { const now = Date.now(); + const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId; let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); @@ -323,12 +371,14 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserRoles(userId: MiUser['id']) { + public async getUserRoles(userOrId: MiUser | MiUser['id']) { const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const assigns = await this.getUserAssigns(userId); + const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId; + const followStats = await this.cacheService.getFollowStats(userId); + const assigns = await this.getUserAssigns(userOrId); 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!, assignedRoles, r.condFormula)); + const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null; + const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats)); return [...assignedRoles, ...matchedCondRoles]; } @@ -336,18 +386,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { * 指定ユーザーのバッジロール一覧取得 */ @bindThis - public async getUserBadgeRoles(userId: MiUser['id']) { + public async getUserBadgeRoles(userOrId: MiUser | MiUser['id']) { const now = Date.now(); + const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId; let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); + const followStats = await this.cacheService.getFollowStats(userId); const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge); 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!, assignedRoles, r.condFormula)); + const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null; + const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats)); return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; } else { return assignedBadgeRoles; @@ -355,12 +407,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getUserPolicies(userId: MiUser['id'] | null): Promise { + public async getUserPolicies(userOrId: MiUser | MiUser['id'] | null): Promise { const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies }; - if (userId == null) return basePolicies; + if (userOrId == null) return basePolicies; - const roles = await this.getUserRoles(userId); + const roles = await this.getUserRoles(userOrId); function calc(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) { if (roles.length === 0) return basePolicies[name]; @@ -376,6 +428,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value)); } + function aggregateChatAvailability(vs: RolePolicies['chatAvailability'][]) { + if (vs.some(v => v === 'available')) return 'available'; + if (vs.some(v => v === 'readonly')) return 'readonly'; + return 'unavailable'; + } + return { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)), @@ -393,6 +451,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { 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)), + maxFileSizeMb: calc('maxFileSizeMb', vs => Math.max(...vs)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), @@ -411,19 +470,21 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), + chatAvailability: calc('chatAvailability', aggregateChatAvailability), + canTrend: calc('canTrend', vs => vs.some(v => v === true)), }; } @bindThis - public async isModerator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise { + public async isModerator(user: { id: MiUser['id'] } | null): Promise { if (user == null) return false; - return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator); + return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator); } @bindThis - public async isAdministrator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise { + public async isAdministrator(user: { id: MiUser['id'] } | null): Promise { if (user == null) return false; - return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isAdministrator); + return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isAdministrator); } @bindThis @@ -472,16 +533,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { .map(a => a.userId), ); - if (includeRoot) { - const rootUserId = await this.rootUserIdCache.fetch(async () => { - const it = await this.usersRepository.createQueryBuilder('users') - .select('id') - .where({ isRoot: true }) - .getRawOne<{ id: string }>(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return it!.id; - }); - resultSet.add(rootUserId); + if (includeRoot && this.meta.rootUserId) { + resultSet.add(this.meta.rootUserId); } return [...resultSet].sort((x, y) => x.localeCompare(y)); @@ -646,6 +699,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { isModerator: values.isModerator, isExplorable: values.isExplorable, asBadge: values.asBadge, + preserveAssignmentOnMoveAccount: values.preserveAssignmentOnMoveAccount, canEditMembersByModerator: values.canEditMembersByModerator, displayOrder: values.displayOrder, policies: values.policies, diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index 37721d2bf1..968a5dcc0b 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -46,6 +46,8 @@ export class S3Service { tls: meta.objectStorageUseSSL, forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted requestHandler: new NodeHttpHandler(handlerOption), + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', }); } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 4782a6c7b0..37238dc4b0 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -300,8 +300,9 @@ export class SearchService { } this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); return await query.limit(pagination.limit).getMany(); } @@ -366,9 +367,14 @@ export class SearchService { 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 => { + + const query = this.notesRepository.createQueryBuilder('note'); + + query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) }); + + this.queryService.generateBlockedHostQueryForNote(query); + + const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; return true; diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 9fc0c2b34a..f1dd0f0503 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -15,13 +15,14 @@ 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 { generateNativeUserToken } from '@/misc/token.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; import { bindThis } from '@/decorators.js'; import UsersChart from '@/core/chart/charts/users.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserService } from '@/core/UserService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class SignupService { @@ -42,7 +43,8 @@ export class SignupService { private userService: UserService, private userEntityService: UserEntityService, private idService: IdService, - private instanceActorService: InstanceActorService, + private systemAccountService: SystemAccountService, + private metaService: MetaService, private usersChart: UsersChart, ) { } @@ -77,7 +79,7 @@ export class SignupService { } // Generate secret - const secret = generateUserToken(); + const secret = generateNativeUserToken(); // Check username duplication if (await this.usersRepository.exists({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { @@ -89,9 +91,7 @@ export class SignupService { throw new Error('USED_USERNAME'); } - const isTheFirstUser = !await this.instanceActorService.realLocalUsersPresent(); - - if (!opts.ignorePreservedUsernames && !isTheFirstUser) { + if (!opts.ignorePreservedUsernames && this.meta.rootUserId != null) { const isPreserved = this.meta.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); if (isPreserved) { throw new Error('USED_USERNAME'); @@ -132,8 +132,7 @@ export class SignupService { usernameLower: username.toLowerCase(), host: this.utilityService.toPunyNullable(host), token: secret, - isRoot: isTheFirstUser, - approved: isTheFirstUser || (opts.approved ?? !this.meta.approvalRequiredForSignup), + approved: opts.approved ?? !this.meta.approvalRequiredForSignup, signupReason: reason, enableRss: false, })); @@ -159,6 +158,10 @@ export class SignupService { this.usersChart.update(account, true); this.userService.notifySystemWebhook(account, 'userCreated'); + if (this.meta.rootUserId == null) { + await this.metaService.update({ rootUserId: account.id }); + } + return { account, secret }; } } diff --git a/packages/backend/src/core/SponsorsService.ts b/packages/backend/src/core/SponsorsService.ts index 77dd6f81a4..3769378f5a 100644 --- a/packages/backend/src/core/SponsorsService.ts +++ b/packages/backend/src/core/SponsorsService.ts @@ -4,24 +4,47 @@ */ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import type { MiMeta } from '@/models/_.js'; import * as Redis from 'ioredis'; +import type { MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { RedisKVCache } from '@/misc/cache.js'; import { bindThis } from '@/decorators.js'; +export interface Sponsor { + MemberId: number; + createdAt: string; + type: string; + role: string; + tier: string; + isActive: boolean; + totalAmountDonated: number; + currency: string; + lastTransactionAt: string; + lastTransactionAmount: number; + profile: string; + name: string; + company: string | null; + description: string | null; + image: string | null; + email: string | null; + newsletterOptIn: unknown | null; + twitter: string | null; + github: string | null; + website: string | null; +} + @Injectable() export class SponsorsService implements OnApplicationShutdown { - private cache: RedisKVCache; + private readonly cache: RedisKVCache; constructor( @Inject(DI.meta) - private meta: MiMeta, + private readonly meta: MiMeta, @Inject(DI.redis) - private redisClient: Redis.Redis, + redisClient: Redis.Redis, ) { - this.cache = new RedisKVCache(this.redisClient, 'sponsors', { + this.cache = new RedisKVCache(redisClient, 'sponsors', { lifetime: 1000 * 60 * 60, memoryCacheLifetime: 1000 * 60, fetcher: (key) => { @@ -34,54 +57,54 @@ export class SponsorsService implements OnApplicationShutdown { } @bindThis - private async fetchInstanceSponsors() { + private async fetchInstanceSponsors(): Promise { if (!(this.meta.donationUrl && this.meta.donationUrl.includes('opencollective.com'))) { return []; } try { - const backers = await fetch(`${this.meta.donationUrl}/members/users.json`).then((response) => response.json()); + const backers = await fetch(`${this.meta.donationUrl}/members/users.json`).then((response) => response.json() as Promise); // Merge both together into one array and make sure it only has Active subscriptions - const allSponsors = [...backers].filter(sponsor => sponsor.isActive === true && sponsor.role === 'BACKER' && sponsor.tier); + const allSponsors = [...backers].filter(sponsor => sponsor.isActive && sponsor.role === 'BACKER' && sponsor.tier); // Remove possible duplicates return [...new Map(allSponsors.map(v => [v.profile, v])).values()]; - } catch (error) { + } catch { return []; } } @bindThis - private async fetchSharkeySponsors() { + private async fetchSharkeySponsors(): Promise { try { - const backers = await fetch('https://opencollective.com/sharkey/tiers/backer/all.json').then((response) => response.json()); - const sponsorsOC = await fetch('https://opencollective.com/sharkey/tiers/sponsor/all.json').then((response) => response.json()); + const backers = await fetch('https://opencollective.com/sharkey/tiers/backer/all.json').then((response) => response.json() as Promise); + const sponsorsOC = await fetch('https://opencollective.com/sharkey/tiers/sponsor/all.json').then((response) => response.json() as Promise); // Merge both together into one array and make sure it only has Active subscriptions - const allSponsors = [...sponsorsOC, ...backers].filter(sponsor => sponsor.isActive === true); + const allSponsors = [...sponsorsOC, ...backers].filter(sponsor => sponsor.isActive); // Remove possible duplicates return [...new Map(allSponsors.map(v => [v.profile, v])).values()]; - } catch (error) { + } catch { return []; } } @bindThis public async instanceSponsors(forceUpdate: boolean) { - if (forceUpdate) this.cache.refresh('instance'); + if (forceUpdate) await this.cache.refresh('instance'); return this.cache.fetch('instance'); } @bindThis public async sharkeySponsors(forceUpdate: boolean) { - if (forceUpdate) this.cache.refresh('sharkey'); + if (forceUpdate) await this.cache.refresh('sharkey'); return this.cache.fetch('sharkey'); } @bindThis - public onApplicationShutdown(signal?: string | undefined): void { + public onApplicationShutdown(): void { this.cache.dispose(); } } diff --git a/packages/backend/src/core/SystemAccountService.ts b/packages/backend/src/core/SystemAccountService.ts new file mode 100644 index 0000000000..1288dc6ffa --- /dev/null +++ b/packages/backend/src/core/SystemAccountService.ts @@ -0,0 +1,232 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import type { OnApplicationShutdown } from '@nestjs/common'; +import { DataSource, IsNull } from 'typeorm'; +import * as Redis from 'ioredis'; +import bcrypt from 'bcryptjs'; +import { MiLocalUser, MiUser } from '@/models/User.js'; +import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js'; +import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { MemoryKVCache } from '@/misc/cache.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { generateNativeUserToken } from '@/misc/token.js'; +import { IdService } from '@/core/IdService.js'; +import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; + +export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const; + +@Injectable() +export class SystemAccountService implements OnApplicationShutdown { + private cache: MemoryKVCache; + + constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.meta) + private meta: MiMeta, + + @Inject(DI.systemAccountsRepository) + private systemAccountsRepository: SystemAccountsRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private idService: IdService, + ) { + this.cache = new MemoryKVCache(1000 * 60 * 10); // 10m + + 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': { + if (body.before != null && body.before.name !== body.after.name) { + for (const account of SYSTEM_ACCOUNT_TYPES) { + await this.updateCorrespondingUserProfile(account, { + name: body.after.name, + }); + } + } + break; + } + default: + break; + } + } + } + + @bindThis + public async list(): Promise { + const accounts = await this.systemAccountsRepository.findBy({}); + + return accounts; + } + + @bindThis + public async fetch(type: typeof SYSTEM_ACCOUNT_TYPES[number]): Promise { + const cached = this.cache.get(type); + if (cached) return cached; + + const systemAccount = await this.systemAccountsRepository.findOne({ + where: { type: type }, + relations: ['user'], + }); + + if (systemAccount) { + this.cache.set(type, systemAccount.user as MiLocalUser); + return systemAccount.user as MiLocalUser; + } else { + const created = await this.createCorrespondingUser(type, { + username: `system.${type}`, // NOTE: (できれば避けたいが) . が含まれるかどうかでシステムアカウントかどうかを判定している処理もあるので変えないように + name: this.meta.name, + }); + this.cache.set(type, created); + return created; + } + } + + @bindThis + private async createCorrespondingUser(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: { + username: MiUser['username']; + name?: MiUser['name']; + }): Promise { + const password = randomUUID(); + + // 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(); + + let account!: MiUser; + + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOneBy(MiUser, { + usernameLower: extra.username.toLowerCase(), + host: IsNull(), + }); + + if (exist) { + account = exist; + return; + } + + account = await transactionalEntityManager.insert(MiUser, { + id: this.idService.gen(), + username: extra.username, + usernameLower: extra.username.toLowerCase(), + host: null, + token: secret, + isLocked: true, + isExplorable: false, + isBot: true, + name: extra.name, + // System accounts are automatically approved. + approved: true, + // We always allow requests to system accounts to avoid federation infinite loop. + // 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). + // If we try to check their signature on *that* request, we'll fetch *their* instance actor... leading to an infinite recursion + allowUnsignedFetch: 'always', + }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); + + await transactionalEntityManager.insert(MiUserKeypair, { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + userId: account.id, + }); + + await transactionalEntityManager.insert(MiUserProfile, { + userId: account.id, + autoAcceptFollowed: false, + password: hash, + }); + + await transactionalEntityManager.insert(MiUsedUsername, { + createdAt: new Date(), + username: extra.username.toLowerCase(), + }); + + await transactionalEntityManager.insert(MiSystemAccount, { + id: this.idService.gen(), + userId: account.id, + type: type, + }); + }); + + return account as MiLocalUser; + } + + @bindThis + public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: { + name?: string | null; + description?: MiUserProfile['description']; + }): Promise { + const user = await this.fetch(type); + + const updates = {} as Partial; + if (extra.name !== undefined) updates.name = extra.name; + + if (Object.keys(updates).length > 0) { + await this.usersRepository.update(user.id, updates); + } + + const profileUpdates = {} as Partial; + if (extra.description !== undefined) profileUpdates.description = extra.description; + + if (Object.keys(profileUpdates).length > 0) { + await this.userProfilesRepository.update(user.id, profileUpdates); + } + + const updated = await this.usersRepository.findOneByOrFail({ id: user.id }) as MiLocalUser; + this.cache.set(type, updated); + + return updated; + } + + public async getInstanceActor() { + return await this.fetch('actor'); + } + + public async getRelayActor() { + return await this.fetch('relay'); + } + + public async getProxyActor() { + return await this.fetch('proxy'); + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + this.cache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/UpdateInstanceQueue.ts b/packages/backend/src/core/UpdateInstanceQueue.ts new file mode 100644 index 0000000000..3fcd215ffa --- /dev/null +++ b/packages/backend/src/core/UpdateInstanceQueue.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; +import { bindThis } from '@/decorators.js'; +import { MiNote } from '@/models/Note.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; + +type UpdateInstanceJob = { + latestRequestReceivedAt: Date, + shouldUnsuspend: boolean, +}; + +// Moved from InboxProcessorService to allow access from ApInboxService +@Injectable() +export class UpdateInstanceQueue extends CollapsedQueue implements OnApplicationShutdown { + constructor( + private readonly federatedInstanceService: FederatedInstanceService, + ) { + super(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, (id, job) => this.collapseUpdateInstanceJobs(id, job), (id, job) => this.performUpdateInstance(id, job)); + } + + @bindThis + private collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) { + const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt + ? newJob.latestRequestReceivedAt + : oldJob.latestRequestReceivedAt; + const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend; + return { + latestRequestReceivedAt, + shouldUnsuspend, + }; + } + + @bindThis + private async performUpdateInstance(id: string, job: UpdateInstanceJob) { + await this.federatedInstanceService.update(id, { + latestRequestReceivedAt: new Date(), + isNotResponding: false, + // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる + suspensionState: job.shouldUnsuspend ? 'none' : undefined, + }); + } + + @bindThis + async onApplicationShutdown() { + await this.performAllNow(); + } +} diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 8da1bb2092..1a1e7c4778 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -77,8 +77,10 @@ export class UserBlockingService implements OnModuleInit { await this.blockingsRepository.insert(blocking); - this.cacheService.userBlockingCache.refresh(blocker.id); - this.cacheService.userBlockedCache.refresh(blockee.id); + await Promise.all([ + this.cacheService.userBlockingCache.delete(blocker.id), + this.cacheService.userBlockedCache.delete(blockee.id), + ]); this.globalEventService.publishInternalEvent('blockingCreated', { blockerId: blocker.id, @@ -168,8 +170,10 @@ export class UserBlockingService implements OnModuleInit { await this.blockingsRepository.delete(blocking.id); - this.cacheService.userBlockingCache.refresh(blocker.id); - this.cacheService.userBlockedCache.refresh(blockee.id); + await Promise.all([ + this.cacheService.userBlockingCache.delete(blocker.id), + this.cacheService.userBlockedCache.delete(blockee.id), + ]); this.globalEventService.publishInternalEvent('blockingDeleted', { blockerId: blocker.id, diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index b98ca97ec9..8470872eac 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { IsNull } from 'typeorm'; +import { Brackets, 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'; @@ -28,9 +28,9 @@ import type { Config } from '@/config.js'; import { AccountMoveService } from '@/core/AccountMoveService.js'; import { UtilityService } from '@/core/UtilityService.js'; import type { ThinUser } from '@/queue/types.js'; -import Logger from '../logger.js'; - -const logger = new Logger('following/create'); +import { LoggerService } from '@/core/LoggerService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; +import type Logger from '../logger.js'; type Local = MiLocalUser | { id: MiLocalUser['id']; @@ -48,6 +48,7 @@ type Both = Local | Remote; @Injectable() export class UserFollowingService implements OnModuleInit { private userBlockingService: UserBlockingService; + private readonly logger: Logger; constructor( private moduleRef: ModuleRef, @@ -86,7 +87,11 @@ export class UserFollowingService implements OnModuleInit { private accountMoveService: AccountMoveService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, + private readonly internalEventService: InternalEventService, + + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('following/create'); } onModuleInit() { @@ -142,12 +147,7 @@ export class UserFollowingService implements OnModuleInit { if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); } - if (await this.followingsRepository.exists({ - where: { - followerId: follower.id, - followeeId: followee.id, - }, - })) { + if (await this.cacheService.isFollowing(follower, followee)) { // すでにフォロー関係が存在している場合 if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { // リモート → ローカル: acceptを送り返しておしまい @@ -175,24 +175,14 @@ export class UserFollowingService implements OnModuleInit { let autoAccept = false; // 鍵アカウントであっても、既にフォローされていた場合はスルー - const isFollowing = await this.followingsRepository.exists({ - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); + const isFollowing = await this.cacheService.isFollowing(follower, followee); if (isFollowing) { autoAccept = true; } // フォローしているユーザーは自動承認オプション if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { - const isFollowed = await this.followingsRepository.exists({ - where: { - followerId: followee.id, - followeeId: follower.id, - }, - }); + const isFollowed = await this.cacheService.isFollowing(followee, follower); // intentionally reversed parameters if (isFollowed) autoAccept = true; } @@ -201,12 +191,7 @@ export class UserFollowingService implements OnModuleInit { if (followee.isLocked && !autoAccept) { autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs( follower, - (oldSrc, newSrc) => this.followingsRepository.exists({ - where: { - followeeId: followee.id, - followerId: newSrc.id, - }, - }), + (oldSrc, newSrc) => this.cacheService.isFollowing(newSrc, followee), true, )); } @@ -254,14 +239,15 @@ export class UserFollowingService implements OnModuleInit { 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}`); + this.logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); alreadyFollowed = true; } else { throw err; } }); - this.cacheService.userFollowingsCache.refresh(follower.id); + // Handled by CacheService + //this.cacheService.userFollowingsCache.refresh(follower.id); const requestExist = await this.followRequestsRepository.exists({ where: { @@ -288,7 +274,7 @@ export class UserFollowingService implements OnModuleInit { }, followee.id); } - this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); + await this.internalEventService.emit('follow', { followerId: follower.id, followeeId: followee.id }); const [followeeUser, followerUser] = await Promise.all([ this.usersRepository.findOneByOrFail({ id: followee.id }), @@ -360,31 +346,29 @@ export class UserFollowingService implements OnModuleInit { }, silent = false, ): Promise { - const following = await this.followingsRepository.findOne({ - relations: { - follower: true, - followee: true, - }, - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); + const [ + followerUser, + followeeUser, + following, + ] = await Promise.all([ + this.cacheService.findUserById(follower.id), + this.cacheService.findUserById(followee.id), + this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)), + ]); - if (following === null || !following.follower || !following.followee) { - logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); + if (following == null) { + this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } await this.followingsRepository.delete(following.id); + await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id }); - this.cacheService.userFollowingsCache.refresh(follower.id); - - this.decrementFollowing(following.follower, following.followee); + this.decrementFollowing(followerUser, followeeUser); if (!silent && this.userEntityService.isLocalUser(follower)) { // Publish unfollow event - this.userEntityService.pack(followee.id, follower, { + this.userEntityService.pack(followeeUser, follower, { schema: 'UserDetailedNotMe', }).then(async packed => { this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); @@ -409,8 +393,6 @@ export class UserFollowingService implements OnModuleInit { 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 @@ -684,22 +666,22 @@ export class UserFollowingService implements OnModuleInit { */ @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, - }, - }); + const [ + followerUser, + followeeUser, + following, + ] = await Promise.all([ + this.cacheService.findUserById(follower.id), + this.cacheService.findUserById(followee.id), + this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)), + ]); - if (!following || !following.followee || !following.follower) return; + if (!following) return; await this.followingsRepository.delete(following.id); + await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id }); - this.decrementFollowing(following.follower, following.followee); + this.decrementFollowing(followerUser, followeeUser); } /** @@ -730,10 +712,26 @@ export class UserFollowingService implements OnModuleInit { } @bindThis - public getFollowees(userId: MiUser['id']) { - return this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: userId }) - .getMany(); + public async getFollowees(userId: MiUser['id']) { + const followings = await this.cacheService.userFollowingsCache.fetch(userId); + return Array.from(followings.values()); + } + + @bindThis + public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) { + return this.cacheService.isFollowing(followerId, followeeId); + } + + @bindThis + public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) { + const [ + isFollowing, + isFollowed, + ] = await Promise.all([ + this.isFollowing(aUserId, bUserId), + this.isFollowing(bUserId, aUserId), + ]); + + return isFollowing && isFollowed; } } diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index 92d61cd103..d8a67d273b 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -7,14 +7,14 @@ 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 { MemoryKVCache, 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; + private cache: MemoryKVCache; constructor( @Inject(DI.redis) @@ -23,18 +23,12 @@ export class UserKeypairService implements OnApplicationShutdown { @Inject(DI.userKeypairsRepository) private userKeypairsRepository: UserKeypairsRepository, ) { - this.cache = new RedisKVCache(this.redisClient, 'userKeypair', { - lifetime: 1000 * 60 * 60 * 24, // 24h - memoryCacheLifetime: 1000 * 60 * 60, // 1h - fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), - toRedisConverter: (value) => JSON.stringify(value), - fromRedisConverter: (value) => JSON.parse(value), - }); + this.cache = new MemoryKVCache(1000 * 60 * 60 * 24); // 24h } @bindThis public async getUserKeypair(userId: MiUser['id']): Promise { - return await this.cache.fetch(userId); + return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId })); } @bindThis diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index 4f4d59a02c..b4486b9808 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -6,26 +6,27 @@ 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 { MiMeta, 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 type { GlobalEvents, InternalEventTypes } 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 { QuantumKVCache } from '@/misc/QuantumKVCache.js'; import { RoleService } from '@/core/RoleService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; @Injectable() export class UserListService implements OnApplicationShutdown, OnModuleInit { public static TooManyUsersError = class extends Error {}; - public membersCache: RedisKVCache>; + public membersCache: QuantumKVCache>; private roleService: RoleService; constructor( @@ -40,21 +41,23 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { @Inject(DI.userListMembershipsRepository) private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.meta) + private readonly meta: MiMeta, + private userEntityService: UserEntityService, private idService: IdService, private globalEventService: GlobalEventService, - private proxyAccountService: ProxyAccountService, private queueService: QueueService, + private systemAccountService: SystemAccountService, + private readonly internalEventService: InternalEventService, ) { - this.membersCache = new RedisKVCache>(this.redisClient, 'userListMembers', { + this.membersCache = new QuantumKVCache>(this.internalEventService, '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); + this.internalEventService.on('userListMemberAdded', this.onMessage); + this.internalEventService.on('userListMemberRemoved', this.onMessage); } async onModuleInit() { @@ -62,15 +65,12 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { } @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']; + private async onMessage(body: InternalEventTypes[E], type: E): Promise { + { switch (type) { case 'userListMemberAdded': { const { userListId, memberId } = body; - const members = await this.membersCache.get(userListId); + const members = this.membersCache.get(userListId); if (members) { members.add(memberId); } @@ -78,7 +78,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { } case 'userListMemberRemoved': { const { userListId, memberId } = body; - const members = await this.membersCache.get(userListId); + const members = this.membersCache.get(userListId); if (members) { members.delete(memberId); } @@ -110,11 +110,9 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { 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 } }]); - } + if (this.userEntityService.isRemoteUser(target) && this.meta.enableProxyAccount) { + const proxy = await this.systemAccountService.fetch('proxy'); + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]); } } @@ -149,7 +147,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { @bindThis public dispose(): void { - this.redisForSub.off('message', this.onMessage); + this.internalEventService.off('userListMemberAdded', this.onMessage); + this.internalEventService.off('userListMemberRemoved', this.onMessage); this.membersCache.dispose(); } diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts index 06643be5fb..c15a979d0f 100644 --- a/packages/backend/src/core/UserMutingService.ts +++ b/packages/backend/src/core/UserMutingService.ts @@ -32,7 +32,7 @@ export class UserMutingService { muteeId: target.id, }); - this.cacheService.userMutingsCache.refresh(user.id); + await this.cacheService.userMutingsCache.delete(user.id); } @bindThis @@ -43,9 +43,6 @@ export class UserMutingService { 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); - } + await this.cacheService.userMutingsCache.deleteMany(mutings.map(m => m.muterId)); } } diff --git a/packages/backend/src/core/UserRenoteMutingService.ts b/packages/backend/src/core/UserRenoteMutingService.ts index bdc5e23f4b..7c0693f216 100644 --- a/packages/backend/src/core/UserRenoteMutingService.ts +++ b/packages/backend/src/core/UserRenoteMutingService.ts @@ -33,7 +33,7 @@ export class UserRenoteMutingService { muteeId: target.id, }); - await this.cacheService.renoteMutingsCache.refresh(user.id); + await this.cacheService.renoteMutingsCache.delete(user.id); } @bindThis @@ -44,9 +44,6 @@ export class UserRenoteMutingService { id: In(mutings.map(m => m.id)), }); - const muterIds = [...new Set(mutings.map(m => m.muterId))]; - for (const muterId of muterIds) { - await this.cacheService.renoteMutingsCache.refresh(muterId); - } + await this.cacheService.renoteMutingsCache.deleteMany(mutings.map(m => m.muterId)); } } diff --git a/packages/backend/src/core/UserSearchService.ts b/packages/backend/src/core/UserSearchService.ts index 0d03cf6ee0..4be7bd9bdb 100644 --- a/packages/backend/src/core/UserSearchService.ts +++ b/packages/backend/src/core/UserSearchService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, SelectQueryBuilder } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js'; +import { type FollowingsRepository, MiUser, type MutingsRepository, type UserProfilesRepository, type UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import type { Config } from '@/config.js'; @@ -22,10 +22,19 @@ export class UserSearchService { constructor( @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.mutingsRepository) + private mutingsRepository: MutingsRepository, + private userEntityService: UserEntityService, ) { } @@ -58,7 +67,7 @@ export class UserSearchService { * @see {@link UserSearchService#buildSearchUserNoLoginQueries} */ @bindThis - public async search( + public async searchByUsernameAndHost( params: { username?: string | null, host?: string | null, @@ -202,4 +211,91 @@ export class UserSearchService { return userQuery; } + + @bindThis + public async search(query: string, meId: MiUser['id'] | null, options: Partial<{ + limit: number; + offset: number; + origin: 'local' | 'remote' | 'combined'; + }> = {}) { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + const isUsername = query.startsWith('@') && !query.includes(' ') && query.indexOf('@', 1) === -1; + + let users: MiUser[] = []; + + const mutingQuery = meId == null ? null : this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: meId }); + + const nameQuery = this.usersRepository.createQueryBuilder('user') + .where(new Brackets(qb => { + qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' }); + + if (isUsername) { + qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(query.replace('@', '').toLowerCase()) + '%' }); + } else if (this.userEntityService.validateLocalUsername(query)) { // Also search username if it qualifies as username + qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(query.toLowerCase()) + '%' }); + } + })) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); + + if (mutingQuery) { + nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`); + nameQuery.setParameters(mutingQuery.getParameters()); + } + + if (options.origin === 'local') { + nameQuery.andWhere('user.host IS NULL'); + } else if (options.origin === 'remote') { + nameQuery.andWhere('user.host IS NOT NULL'); + } + + users = await nameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .limit(options.limit) + .offset(options.offset) + .getMany(); + + if (users.length < (options.limit ?? 30)) { + const profQuery = this.userProfilesRepository.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' }); + + if (mutingQuery) { + profQuery.andWhere(`prof.userId NOT IN (${mutingQuery.getQuery()})`); + profQuery.setParameters(mutingQuery.getParameters()); + } + + if (options.origin === 'local') { + profQuery.andWhere('prof.userHost IS NULL'); + } else if (options.origin === 'remote') { + profQuery.andWhere('prof.userHost IS NOT NULL'); + } + + const userQuery = 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 userQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .limit(options.limit) + .offset(options.offset) + .getMany(), + ); + } + + return users; + } } diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts index 1f471513f3..4a04910105 100644 --- a/packages/backend/src/core/UserService.ts +++ b/packages/backend/src/core/UserService.ts @@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class UserService { @@ -20,6 +21,7 @@ export class UserService { private followingsRepository: FollowingsRepository, private systemWebhookService: SystemWebhookService, private userEntityService: UserEntityService, + private readonly cacheService: CacheService, ) { } @@ -38,14 +40,17 @@ export class UserService { }); const wokeUp = result.isHibernated; if (wokeUp) { - this.usersRepository.update(user.id, { - isHibernated: false, - }); - this.followingsRepository.update({ - followerId: user.id, - }, { - isFollowerHibernated: false, - }); + await Promise.all([ + this.usersRepository.update(user.id, { + isHibernated: false, + }), + this.followingsRepository.update({ + followerId: user.id, + }, { + isFollowerHibernated: false, + }), + this.cacheService.hibernatedUserCache.set(user.id, false), + ]); } } else { this.usersRepository.update(user.id, { diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 30dcaa6f7d..ddadab7022 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Not, IsNull } from 'typeorm'; import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; +import { MiUser } from '@/models/User.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -16,9 +16,16 @@ import { bindThis } from '@/decorators.js'; import { RelationshipJobData } from '@/queue/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; +import { CacheService } from '@/core/CacheService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +import { trackPromise } from '@/misc/promise-tracker.js'; @Injectable() export class UserSuspendService { + private readonly logger: Logger; + constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -34,7 +41,11 @@ export class UserSuspendService { private globalEventService: GlobalEventService, private apRendererService: ApRendererService, private moderationLogService: ModerationLogService, + private readonly cacheService: CacheService, + + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('user-suspend'); } @bindThis @@ -45,16 +56,16 @@ export class UserSuspendService { isSuspended: true, }); - this.moderationLogService.log(moderator, 'suspend', { + await this.moderationLogService.log(moderator, 'suspend', { userId: user.id, userUsername: user.username, userHost: user.host, }); - (async () => { - await this.postSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); - })(); + trackPromise((async () => { + await this.postSuspend(user); + await this.freezeAll(user); + })().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`))); } @bindThis @@ -63,33 +74,36 @@ export class UserSuspendService { isSuspended: false, }); - this.moderationLogService.log(moderator, 'unsuspend', { + await this.moderationLogService.log(moderator, 'unsuspend', { userId: user.id, userUsername: user.username, userHost: user.host, }); - (async () => { - await this.postUnsuspend(user).catch(e => {}); - })(); + trackPromise((async () => { + await this.postUnsuspend(user); + await this.unFreezeAll(user); + })().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`))); } @bindThis private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); + /* this.followRequestsRepository.delete({ followeeId: user.id, }); this.followRequestsRepository.delete({ followerId: user.id, }); + */ 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 queue = new Map(); const followings = await this.followingsRepository.find({ where: [ @@ -102,12 +116,12 @@ export class UserSuspendService { const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + if (inbox != null) { + queue.set(inbox, true); + } } - for (const inbox of queue) { - this.queueService.deliver(user, content, inbox, true); - } + await this.queueService.deliverMany(user, content, queue); } } @@ -119,7 +133,7 @@ export class UserSuspendService { // 知り得る全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 queue = new Map(); const followings = await this.followingsRepository.find({ where: [ @@ -132,23 +146,19 @@ export class UserSuspendService { const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + if (inbox != null) { + queue.set(inbox, true); + } } - for (const inbox of queue) { - this.queueService.deliver(user as any, content, inbox, true); - } + await this.queueService.deliverMany(user, content, queue); } } @bindThis private async unFollowAll(follower: MiUser) { - const followings = await this.followingsRepository.find({ - where: { - followerId: follower.id, - followeeId: Not(IsNull()), - }, - }); + const followings = await this.cacheService.userFollowingsCache.fetch(follower.id) + .then(fs => Array.from(fs.values()).filter(f => f.followeeHost != null)); const jobs: RelationshipJobData[] = []; for (const following of followings) { @@ -162,4 +172,36 @@ export class UserSuspendService { } this.queueService.createUnfollowJob(jobs); } + + @bindThis + private async freezeAll(user: MiUser): Promise { + // Freeze follow relations with all remote users + await this.followingsRepository + .createQueryBuilder('following') + .orWhere({ + followeeId: user.id, + followerHost: Not(IsNull()), + }) + .update({ + isFollowerHibernated: true, + }) + .execute(); + } + + @bindThis + private async unFreezeAll(user: MiUser): Promise { + // Restore follow relations with all remote users + await this.followingsRepository + .createQueryBuilder('following') + .innerJoin(MiUser, 'follower', 'user.id = following.followerId') + .andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen + .andWhere({ + followeeId: user.id, + followerHost: Not(IsNull()), + }) + .update({ + isFollowerHibernated: false, + }) + .execute(); + } } diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts index 08db4c9afc..2f79eb429a 100644 --- a/packages/backend/src/core/UserWebhookService.ts +++ b/packages/backend/src/core/UserWebhookService.ts @@ -15,7 +15,7 @@ import { QueueService } from '@/core/QueueService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; export type UserWebhookPayload = - T extends 'note' | 'reply' | 'renote' |'mention' | 'edited' ? { + T extends 'note' | 'reply' | 'renote' | 'mention' | 'edited' ? { note: Packed<'Note'>, } : T extends 'follow' | 'unfollow' ? { diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 81eaa5f95d..3098367392 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -39,22 +39,59 @@ export class UtilityService { return this.punyHost(uri) === this.toPuny(this.config.host); } + // メールアドレスのバリデーションを行う + // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address @bindThis - public isBlockedHost(blockedHosts: string[], host: string | null): boolean { + public validateEmailFormat(email: string): boolean { + // Note: replaced MK's complicated regex with a simpler one that is more efficient and reliable. + const regexp = /^.+@.+$/; + //const regexp = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + return regexp.test(email); + } + + public isBlockedHost(host: string | null): boolean; + public isBlockedHost(blockedHosts: string[], host: string | null): boolean; + @bindThis + public isBlockedHost(blockedHostsOrHost: string[] | string | null, host?: string | null): boolean { + const blockedHosts = Array.isArray(blockedHostsOrHost) ? blockedHostsOrHost : this.meta.blockedHosts; + host = Array.isArray(blockedHostsOrHost) ? host : blockedHostsOrHost; + if (host == null) return false; return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); } + public isSilencedHost(host: string | null): boolean; + public isSilencedHost(silencedHosts: string[], host: string | null): boolean; @bindThis - public isSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { - if (!silencedHosts || host == null) return false; + public isSilencedHost(silencedHostsOrHost: string[] | string | null, host?: string | null): boolean { + const silencedHosts = Array.isArray(silencedHostsOrHost) ? silencedHostsOrHost : this.meta.silencedHosts; + host = Array.isArray(silencedHostsOrHost) ? host : silencedHostsOrHost; + + if (host == null) return false; return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); } + public isMediaSilencedHost(host: string | null): boolean; + public isMediaSilencedHost(silencedHosts: string[], host: string | null): boolean; @bindThis - public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { - if (!silencedHosts || host == null) return false; - return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); + public isMediaSilencedHost(mediaSilencedHostsOrHost: string[] | string | null, host?: string | null): boolean { + const mediaSilencedHosts = Array.isArray(mediaSilencedHostsOrHost) ? mediaSilencedHostsOrHost : this.meta.mediaSilencedHosts; + host = Array.isArray(mediaSilencedHostsOrHost) ? host : mediaSilencedHostsOrHost; + + if (host == null) return false; + return mediaSilencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); + } + + @bindThis + public isAllowListedHost(host: string | null): boolean { + if (host == null) return false; + return this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); + } + + @bindThis + public isBubbledHost(host: string | null): boolean { + if (host == null) return false; + return this.meta.bubbleInstances.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); } @bindThis @@ -106,13 +143,22 @@ export class UtilityService { @bindThis public toPuny(host: string): string { - return domainToASCII(host.toLowerCase()); + // domainToASCII will return an empty string if we give it a + // string like `name:123`, but `host` may well be in that form + // (e.g. when testing locally, you'll get `localhost:3000`); split + // the port off, and add it back later + const hostParts = host.toLowerCase().match(/^(.+?)(:.+)?$/); + if (!hostParts) return ''; + const hostname = hostParts[1]; + const port = hostParts[2] ?? ''; + + return domainToASCII(hostname) + port; } @bindThis public toPunyNullable(host: string | null | undefined): string | null { if (host == null) return null; - return domainToASCII(host.toLowerCase()); + return this.toPuny(host); } @bindThis @@ -157,4 +203,14 @@ export class UtilityService { const host = this.extractDbHost(uri); return this.isFederationAllowedHost(host); } + + @bindThis + public getUrlScheme(url: string): string { + try { + // Returns in the format "https:" or an empty string + return new URL(url).protocol; + } catch { + return ''; + } + } } diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index 747fe4fc7e..3e4fd6a4b0 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -3,24 +3,41 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import fs from 'node:fs/promises'; 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 { createTemp, createTempDir } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { appendQuery, query } from '@/misc/prelude/url.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; + +// faststart is only supported for MP4, M4A, M4W and MOV files (the MOV family). +// WebM (and Matroska) files always support faststart-like behavior. +const supportedMimeTypes = new Map([ + ['video/mp4', 'mp4'], + ['video/m4a', 'mp4'], + ['video/m4v', 'mp4'], + ['video/quicktime', 'mov'], +]); @Injectable() export class VideoProcessingService { + private readonly logger: Logger; + constructor( @Inject(DI.config) private config: Config, private imageProcessingService: ImageProcessingService, + + private loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('video-processing'); } @bindThis @@ -60,5 +77,50 @@ export class VideoProcessingService { }), ); } + + /** + * Optimize video for web playback by adding faststart flag. + * This allows the video to start playing before it is fully downloaded. + * The original file is modified in-place. + * @param source Path to the video file + * @param mimeType The MIME type of the video + * @returns Promise that resolves when optimization is complete + */ + @bindThis + public async webOptimizeVideo(source: string, mimeType: string): Promise { + const outputFormat = supportedMimeTypes.get(mimeType); + if (!outputFormat) { + this.logger.debug(`Skipping web optimization for unsupported MIME type: ${mimeType}`); + return; + } + + const [tempPath, cleanup] = await createTemp(); + + try { + await new Promise((resolve, reject) => { + FFmpeg(source) + .format(outputFormat) // Specify output format + .addOutputOptions('-c copy') // Copy streams without re-encoding + .addOutputOptions('-movflags +faststart') + .on('error', reject) + .on('end', async () => { + try { + // Replace original file with optimized version + await fs.copyFile(tempPath, source); + this.logger.info(`Web-optimized video: ${source}`); + resolve(); + } catch (copyError) { + reject(copyError); + } + }) + .save(tempPath); + }); + } catch (error) { + this.logger.warn(`Failed to web-optimize video: ${source}`, { error }); + throw error; + } finally { + cleanup(); + } + } } diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index ed75e4f467..afd1d68ce4 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -17,6 +17,8 @@ import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; import { MiUser } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; import type { AuthenticationResponseJSON, AuthenticatorTransportFuture, @@ -28,6 +30,8 @@ import type { @Injectable() export class WebAuthnService { + private readonly logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -40,7 +44,9 @@ export class WebAuthnService { @Inject(DI.userSecurityKeysRepository) private userSecurityKeysRepository: UserSecurityKeysRepository, + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('web-authn'); } @bindThis @@ -114,8 +120,8 @@ export class WebAuthnService { requireUserVerification: true, }); } catch (error) { - console.error(error); - throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed'); + this.logger.error(error as Error, 'Error authenticating webauthn'); + throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed', true, error); } const { verified } = verification; @@ -127,11 +133,11 @@ export class WebAuthnService { const { registrationInfo } = verification; return { - credentialID: registrationInfo.credentialID, - credentialPublicKey: registrationInfo.credentialPublicKey, + credentialID: registrationInfo.credential.id, + credentialPublicKey: registrationInfo.credential.publicKey, attestationObject: registrationInfo.attestationObject, fmt: registrationInfo.fmt, - counter: registrationInfo.counter, + counter: registrationInfo.credential.counter, userVerified: registrationInfo.userVerified, credentialDeviceType: registrationInfo.credentialDeviceType, credentialBackedUp: registrationInfo.credentialBackedUp, @@ -212,16 +218,16 @@ export class WebAuthnService { expectedChallenge: challenge, expectedOrigin: relyingParty.origin, expectedRPID: relyingParty.rpId, - authenticator: { - credentialID: key.id, - credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), + credential: { + id: key.id, + publicKey: Buffer.from(key.publicKey, 'base64url'), counter: key.counter, transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, }, requireUserVerification: true, }); } catch (error) { - throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`); + throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed`, true, error); } const { verified, authenticationInfo } = verification; @@ -292,17 +298,17 @@ export class WebAuthnService { expectedChallenge: challenge, expectedOrigin: relyingParty.origin, expectedRPID: relyingParty.rpId, - authenticator: { - credentialID: key.id, - credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), + credential: { + id: key.id, + publicKey: 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'); + this.logger.error(error as Error, 'Error authenticating webauthn'); + throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed', true, error); } const { verified, authenticationInfo } = verification; diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index f57e7a2c1f..bb9f0be4c6 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -5,10 +5,11 @@ import { URL } from 'node:url'; import { Injectable } from '@nestjs/common'; -import { XMLParser } from 'fast-xml-parser'; +import { load as cheerio } from 'cheerio/slim'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { RemoteLoggerService } from './RemoteLoggerService.js'; export type ILink = { @@ -100,16 +101,14 @@ export class WebfingerService { private async fetchWebFingerTemplateFromHostMeta(url: string): Promise { try { const res = await this.httpRequestService.getHtml(url, 'application/xrd+xml'); - const options = { - ignoreAttributes: false, - isArray: (_name: string, jpath: string) => jpath === 'XRD.Link', - }; - const parser = new XMLParser(options); - const hostMeta = parser.parse(res); - const template = (hostMeta['XRD']['Link'] as Array).filter(p => p['@_rel'] === 'lrdd')[0]['@_template']; - return template.indexOf('{uri}') < 0 ? null : template; + const hostMeta = cheerio(res, { + xml: true, + }); + + const template = hostMeta('XRD > Link[rel="lrdd"][template*="{uri}"]').attr('template'); + return template ?? null; } catch (err) { - this.logger.error(`error while request host-meta for ${url}: ${err}`); + this.logger.error(`error while request host-meta for ${url}: ${renderInlineError(err)}`); return null; } } diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 2e50f4472f..8dc42e45c0 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -7,42 +7,17 @@ import { Injectable } from '@nestjs/common'; import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js'; -import { AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js'; -import { Packed } from '@/misc/json-schema.js'; +import { type AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { type Packed } from '@/misc/json-schema.js'; import { type WebhookEventTypes } from '@/models/Webhook.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js'; import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; const oneDayMillis = 24 * 60 * 60 * 1000; -function generateAbuseReport(override?: Partial): AbuseReportPayload { - const result: MiAbuseUserReport = { - id: 'dummy-abuse-report1', - targetUserId: 'dummy-target-user', - targetUser: null, - reporterId: 'dummy-reporter-user', - reporter: null, - assigneeId: null, - assignee: null, - resolved: false, - forwarded: false, - comment: 'This is a dummy report for testing purposes.', - targetUserHost: null, - reporterHost: null, - resolvedAs: null, - moderationNote: 'foo', - ...override, - }; - - return { - ...result, - targetUser: result.targetUser ? toPackedUserLite(result.targetUser) : null, - reporter: result.reporter ? toPackedUserLite(result.reporter) : null, - assignee: result.assignee ? toPackedUserLite(result.assignee) : null, - }; -} - function generateDummyUser(override?: Partial): MiUser { return { id: 'dummy-user-1', @@ -79,16 +54,17 @@ function generateDummyUser(override?: Partial): MiUser { isBot: false, isCat: true, speakAsCat: true, - isRoot: false, isExplorable: true, isHibernated: false, isDeleted: false, requireSigninToViewContents: false, makeNotesFollowersOnlyBefore: null, makeNotesHiddenBefore: null, + chatScope: 'mutual', emojis: [], score: 0, host: null, + instance: null, inbox: null, sharedInbox: null, featured: null, @@ -101,6 +77,9 @@ function generateDummyUser(override?: Partial): MiUser { enableRss: true, mandatoryCW: null, rejectQuotes: false, + allowUnsignedFetch: 'staff', + userProfile: null, + attributionDomains: [], ...override, }; } @@ -139,145 +118,19 @@ function generateDummyNote(override?: Partial): MiNote { channelId: null, channel: null, userHost: null, + userInstance: null, replyUserId: null, replyUserHost: null, + replyUserInstance: null, renoteUserId: null, renoteUserHost: null, + renoteUserInstance: null, updatedAt: null, processErrors: [], ...override, }; } -function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> { - return { - id: note.id, - createdAt: new Date().toISOString(), - deletedAt: null, - text: note.text, - cw: note.cw, - userId: note.userId, - user: toPackedUserLite(note.user ?? generateDummyUser()), - replyId: note.replyId, - renoteId: note.renoteId, - isHidden: false, - visibility: note.visibility, - mentions: note.mentions, - visibleUserIds: note.visibleUserIds, - fileIds: note.fileIds, - files: [], - tags: note.tags, - poll: null, - emojis: note.emojis, - channelId: note.channelId, - channel: note.channel, - localOnly: note.localOnly, - reactionAcceptance: note.reactionAcceptance, - reactionEmojis: {}, - reactions: {}, - reactionCount: 0, - renoteCount: note.renoteCount, - repliesCount: note.repliesCount, - uri: note.uri ?? undefined, - url: note.url ?? undefined, - reactionAndUserPairCache: note.reactionAndUserPairCache, - ...(detail ? { - clippedCount: note.clippedCount, - reply: note.reply ? toPackedNote(note.reply, false) : null, - renote: note.renote ? toPackedNote(note.renote, true) : null, - myReaction: null, - } : {}), - ...override, - }; -} - -function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> { - return { - id: user.id, - name: user.name, - username: user.username, - host: user.host, - avatarUrl: user.avatarUrl, - avatarBlurhash: user.avatarBlurhash, - avatarDecorations: user.avatarDecorations.map(it => ({ - id: it.id, - angle: it.angle, - flipH: it.flipH, - url: 'https://example.com/dummy-image001.png', - offsetX: it.offsetX, - offsetY: it.offsetY, - })), - isBot: user.isBot, - isCat: user.isCat, - speakAsCat: user.speakAsCat, - emojis: user.emojis, - onlineStatus: 'active', - badgeRoles: [], - noindex: user.noindex, - isModerator: false, - isAdmin: false, - isSystem: false, - isSilenced: user.isSilenced, - enableRss: true, - mandatoryCW: null, - ...override, - }; -} - -function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> { - return { - ...toPackedUserLite(user), - url: null, - uri: null, - movedTo: null, - alsoKnownAs: [], - createdAt: new Date().toISOString(), - updatedAt: user.updatedAt?.toISOString() ?? null, - lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, - bannerUrl: user.bannerUrl, - bannerBlurhash: user.bannerBlurhash, - backgroundUrl: user.backgroundUrl, - backgroundBlurhash: user.backgroundBlurhash, - isLocked: user.isLocked, - isSilenced: false, - isSuspended: user.isSuspended, - description: null, - location: null, - birthday: null, - lang: null, - fields: [], - verifiedLinks: [], - followersCount: user.followersCount, - followingCount: user.followingCount, - notesCount: user.notesCount, - pinnedNoteIds: [], - pinnedNotes: [], - pinnedPageId: null, - pinnedPage: null, - publicReactions: true, - followersVisibility: 'public', - followingVisibility: 'public', - twoFactorEnabled: false, - usePasswordLessLogin: false, - securityKeys: false, - roles: [], - memo: null, - moderationNote: undefined, - isFollowing: false, - isFollowed: false, - hasPendingFollowRequestFromYou: false, - hasPendingFollowRequestToYou: false, - isBlocking: false, - isBlocked: false, - isMuted: false, - isRenoteMuted: false, - notify: 'none', - withReplies: true, - listenbrainz: null, - ...override, - }; -} - const dummyUser1 = generateDummyUser(); const dummyUser2 = generateDummyUser({ id: 'dummy-user-2', @@ -310,9 +163,11 @@ export class WebhookTestService { }; constructor( + private customEmojiService: CustomEmojiService, private userWebhookService: UserWebhookService, private systemWebhookService: SystemWebhookService, private queueService: QueueService, + private readonly idService: IdService, ) { } @@ -380,35 +235,35 @@ export class WebhookTestService { switch (params.type) { case 'note': { - send('note', { note: toPackedNote(dummyNote1) }); + send('note', { note: await this.toPackedNote(dummyNote1) }); break; } case 'reply': { - send('reply', { note: toPackedNote(dummyReply1) }); + send('reply', { note: await this.toPackedNote(dummyReply1) }); break; } case 'renote': { - send('renote', { note: toPackedNote(dummyRenote1) }); + send('renote', { note: await this.toPackedNote(dummyRenote1) }); break; } case 'mention': { - send('mention', { note: toPackedNote(dummyMention1) }); + send('mention', { note: await this.toPackedNote(dummyMention1) }); break; } case 'edited': { - send('edited', { note: toPackedNote(dummyNote1) }); + send('edited', { note: await this.toPackedNote(dummyNote1) }); break; } case 'follow': { - send('follow', { user: toPackedUserDetailedNotMe(dummyUser1) }); + send('follow', { user: await this.toPackedUserDetailedNotMe(dummyUser1) }); break; } case 'followed': { - send('followed', { user: toPackedUserLite(dummyUser2) }); + send('followed', { user: await this.toPackedUserLite(dummyUser2) }); break; } case 'unfollow': { - send('unfollow', { user: toPackedUserDetailedNotMe(dummyUser3) }); + send('unfollow', { user: await this.toPackedUserDetailedNotMe(dummyUser3) }); break; } // まだ実装されていない (#9485) @@ -457,7 +312,7 @@ export class WebhookTestService { switch (params.type) { case 'abuseReport': { - send('abuseReport', generateAbuseReport({ + send('abuseReport', await this.generateAbuseReport({ targetUserId: dummyUser1.id, targetUser: dummyUser1, reporterId: dummyUser2.id, @@ -466,7 +321,7 @@ export class WebhookTestService { break; } case 'abuseReportResolved': { - send('abuseReportResolved', generateAbuseReport({ + send('abuseReportResolved', await this.generateAbuseReport({ targetUserId: dummyUser1.id, targetUser: dummyUser1, reporterId: dummyUser2.id, @@ -478,7 +333,7 @@ export class WebhookTestService { break; } case 'userCreated': { - send('userCreated', toPackedUserLite(dummyUser1)); + send('userCreated', await this.toPackedUserLite(dummyUser1)); break; } case 'inactiveModeratorsWarning': { @@ -504,4 +359,167 @@ export class WebhookTestService { } } } + + @bindThis + private async generateAbuseReport(override?: Partial): Promise { + const result: MiAbuseUserReport = { + id: 'dummy-abuse-report1', + targetUserId: 'dummy-target-user', + targetUser: null, + targetUserInstance: null, + reporterId: 'dummy-reporter-user', + reporter: null, + reporterInstance: null, + assigneeId: null, + assignee: null, + resolved: false, + forwarded: false, + comment: 'This is a dummy report for testing purposes.', + targetUserHost: null, + reporterHost: null, + resolvedAs: null, + moderationNote: 'foo', + ...override, + }; + + return { + ...result, + targetUser: result.targetUser ? await this.toPackedUserLite(result.targetUser) : null, + reporter: result.reporter ? await this.toPackedUserLite(result.reporter) : null, + assignee: result.assignee ? await this.toPackedUserLite(result.assignee) : null, + }; + } + + @bindThis + private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise> { + return { + id: note.id, + createdAt: new Date().toISOString(), + deletedAt: null, + text: note.text, + cw: note.cw, + userId: note.userId, + user: await this.toPackedUserLite(note.user ?? generateDummyUser()), + replyId: note.replyId, + renoteId: note.renoteId, + isHidden: false, + visibility: note.visibility, + mentions: note.mentions, + visibleUserIds: note.visibleUserIds, + fileIds: note.fileIds, + files: [], + tags: note.tags, + poll: null, + emojis: await this.customEmojiService.populateEmojis(note.emojis, note.userHost), + channelId: note.channelId, + channel: note.channel, + localOnly: note.localOnly, + reactionAcceptance: note.reactionAcceptance, + reactionEmojis: {}, + reactions: {}, + reactionCount: 0, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + uri: note.uri ?? undefined, + url: note.url ?? undefined, + reactionAndUserPairCache: note.reactionAndUserPairCache, + ...(detail ? { + clippedCount: note.clippedCount, + reply: note.reply ? await this.toPackedNote(note.reply, false) : null, + renote: note.renote ? await this.toPackedNote(note.renote, true) : null, + myReaction: null, + } : {}), + ...override, + }; + } + + @bindThis + private async toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Promise> { + return { + ...user, + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarId == null ? null : user.avatarUrl, + avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash, + avatarDecorations: user.avatarDecorations.map(it => ({ + id: it.id, + angle: it.angle, + flipH: it.flipH, + url: 'https://example.com/dummy-image001.png', + offsetX: it.offsetX, + offsetY: it.offsetY, + })), + createdAt: this.idService.parse(user.id).date.toISOString(), + description: '', + isBot: user.isBot, + isCat: user.isCat, + emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host), + onlineStatus: 'active', + badgeRoles: [], + isAdmin: false, + isModerator: false, + isSystem: false, + instance: undefined, + ...override, + }; + } + + @bindThis + private async toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Promise> { + return { + ...await this.toPackedUserLite(user), + url: null, + uri: null, + movedTo: null, + alsoKnownAs: [], + createdAt: new Date().toISOString(), + updatedAt: user.updatedAt?.toISOString() ?? null, + lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, + bannerUrl: user.bannerId == null ? null : user.bannerUrl, + bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash, + backgroundUrl: user.backgroundId == null ? null : user.backgroundUrl, + backgroundBlurhash: user.backgroundId == null ? null : user.backgroundBlurhash, + listenbrainz: null, + isLocked: user.isLocked, + isSilenced: false, + isSuspended: user.isSuspended, + description: null, + location: null, + birthday: null, + lang: null, + fields: [], + verifiedLinks: [], + followersCount: user.followersCount, + followingCount: user.followingCount, + notesCount: user.notesCount, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPageId: null, + pinnedPage: null, + publicReactions: true, + followersVisibility: 'public', + followingVisibility: 'public', + chatScope: 'mutual', + canChat: true, + twoFactorEnabled: false, + usePasswordLessLogin: false, + securityKeys: false, + roles: [], + memo: null, + moderationNote: undefined, + isFollowing: false, + isFollowed: false, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isBlocking: false, + isBlocked: false, + isMuted: false, + isRenoteMuted: false, + notify: 'none', + withReplies: true, + ...override, + }; + } } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index f6b50ec704..e9e0dde9cd 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -14,10 +14,10 @@ import { UtilityService } from '@/core/UtilityService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js'; import { getApId } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; import type { IObject } from './type.js'; -import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js'; export type UriParseResult = { /** wether the URI was generated by us */ @@ -37,9 +37,6 @@ export type UriParseResult = { @Injectable() export class ApDbResolverService implements OnApplicationShutdown { - private publicKeyCache: MemoryKVCache; - private publicKeyByUserIdCache: MemoryKVCache; - constructor( @Inject(DI.config) private config: Config, @@ -58,8 +55,7 @@ export class ApDbResolverService implements OnApplicationShutdown { private apLoggerService: ApLoggerService, private utilityService: UtilityService, ) { - this.publicKeyCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h - this.publicKeyByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h + // Caches moved to ApPersonService to avoid circular dependency } @bindThis @@ -131,15 +127,7 @@ export class ApDbResolverService implements OnApplicationShutdown { 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); + const key = await this.apPersonService.findPublicKeyByKeyId(keyId); if (key == null) return null; @@ -164,11 +152,7 @@ export class ApDbResolverService implements OnApplicationShutdown { const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser; if (user.isDeleted) return null; - const key = await this.publicKeyByUserIdCache.fetch( - user.id, - () => this.userPublickeysRepository.findOneBy({ userId: user.id }), - v => v != null, - ); + const key = await this.apPersonService.findPublicKeyByUserId(user.id); return { user, @@ -181,24 +165,27 @@ export class ApDbResolverService implements OnApplicationShutdown { */ @bindThis public async refetchPublicKeyForApId(user: MiRemoteUser): Promise { - this.apLoggerService.logger.debug('Re-fetching public key for user', { userId: user.id, uri: user.uri }); + this.apLoggerService.logger.debug(`Updating public key for user ${user.id} (${user.uri})`); + + const oldKey = await this.apPersonService.findPublicKeyByUserId(user.id); await this.apPersonService.updatePerson(user.uri); + const newKey = await this.apPersonService.findPublicKeyByUserId(user.id); - const key = await this.userPublickeysRepository.findOneBy({ userId: user.id }); - this.publicKeyByUserIdCache.set(user.id, key); - - if (key) { - this.apLoggerService.logger.info('Re-fetched public key for user', { userId: user.id, uri: user.uri }); + if (newKey) { + if (oldKey && newKey.keyPem === oldKey.keyPem) { + this.apLoggerService.logger.debug(`Public key is up-to-date for user ${user.id} (${user.uri})`); + } else { + this.apLoggerService.logger.info(`Updated public key for user ${user.id} (${user.uri})`); + } } else { - this.apLoggerService.logger.warn('Failed to re-fetch key for user', { userId: user.id, uri: user.uri }); + this.apLoggerService.logger.warn(`Failed to update public key for user ${user.id} (${user.uri})`); } - return key; + + return newKey ?? oldKey; } @bindThis public dispose(): void { - this.publicKeyCache.dispose(); - this.publicKeyByUserIdCache.dispose(); } @bindThis diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index f045333d2a..91f6f2d9fc 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; -import { UnrecoverableError } from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository } from '@/models/_.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; @@ -14,6 +13,7 @@ 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'; +import { CacheService } from '@/core/CacheService.js'; interface IRecipe { type: string; @@ -41,23 +41,21 @@ class DeliverManager { /** * Constructor - * @param userEntityService - * @param followingsRepository * @param queueService + * @param cacheService * @param actor Actor * @param activity Activity to deliver */ constructor( - private userEntityService: UserEntityService, - private followingsRepository: FollowingsRepository, private queueService: QueueService, + private readonly cacheService: CacheService, 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'); + if (actor.host != null) throw new Error(`deliver failed for ${actor.id}: host is not null`); // パフォーマンス向上のためキューに突っ込むのはidのみに絞る this.actor = { @@ -114,23 +112,23 @@ class DeliverManager { // 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, - }, - }); + const followers = await this.cacheService.userFollowersCache + .fetch(this.actor.id) + .then(f => Array + .from(f.values()) + .filter(f => f.followerHost != null) + .map(f => ({ + followerInbox: f.followerInbox, + followerSharedInbox: f.followerSharedInbox, + }))); for (const following of followers) { - const inbox = following.followerSharedInbox ?? following.followerInbox; - if (inbox === null) throw new UnrecoverableError(`inbox is null: following ${following.id}`); - inboxes.set(inbox, following.followerSharedInbox != null); + if (following.followerSharedInbox) { + inboxes.set(following.followerSharedInbox, true); + } else if (following.followerInbox) { + inboxes.set(following.followerInbox, false); + } } } @@ -152,11 +150,8 @@ class DeliverManager { @Injectable() export class ApDeliverManagerService { constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, private queueService: QueueService, + private readonly cacheService: CacheService, ) { } @@ -168,9 +163,8 @@ export class ApDeliverManagerService { @bindThis public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise { const manager = new DeliverManager( - this.userEntityService, - this.followingsRepository, this.queueService, + this.cacheService, actor, activity, ); @@ -187,9 +181,8 @@ export class ApDeliverManagerService { @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, + this.cacheService, actor, activity, ); @@ -197,12 +190,29 @@ export class ApDeliverManagerService { await manager.execute(); } + /** + * Deliver activity to users + * @param actor + * @param activity Activity + * @param targets Target users + */ + @bindThis + public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise { + const manager = new DeliverManager( + this.queueService, + this.cacheService, + actor, + activity, + ); + for (const to of targets) 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, + this.cacheService, actor, activity, diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 1eef85aeef..009d4cbd39 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -32,7 +32,13 @@ import { AbuseReportService } from '@/core/AbuseReportService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { fromTuple } from '@/misc/from-tuple.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import FederationChart from '@/core/chart/charts/federation.js'; +import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; +import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; +import { CacheService } from '@/core/CacheService.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -41,7 +47,7 @@ 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, IDislike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IDislike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost, IActivity } from './type.js'; @Injectable() export class ApInboxService { @@ -88,7 +94,12 @@ export class ApInboxService { private apQuestionService: ApQuestionService, private queueService: QueueService, private globalEventService: GlobalEventService, - private federatedInstanceService: FederatedInstanceService, + private readonly federatedInstanceService: FederatedInstanceService, + private readonly fetchInstanceMetadataService: FetchInstanceMetadataService, + private readonly instanceChart: InstanceChart, + private readonly federationChart: FederationChart, + private readonly updateInstanceQueue: UpdateInstanceQueue, + private readonly cacheService: CacheService, ) { this.logger = this.apLoggerService.logger; } @@ -98,25 +109,29 @@ export class ApInboxService { let result = undefined as string | void; if (isCollectionOrOrderedCollection(activity)) { const results = [] as [string, string | void][]; - // eslint-disable-next-line no-param-reassign resolver ??= this.apResolverService.createResolver(); - const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems); - if (items.length >= resolver.getRecursionLimit()) { - throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`); - } - - for (const item of items) { - const act = await resolver.resolve(item); - if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { - this.logger.debug('skipping activity: activity id is null or mismatching'); - continue; + const items = await resolver.resolveCollectionItems(activity); + for (let i = 0; i < items.length; i++) { + const act = items[i]; + if (act.id != null) { + if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { + this.logger.warn('skipping activity: activity id mismatch'); + continue; + } + } else { + // Activity ID should only be string or undefined. + act.id = undefined; } + + const id = getNullableApId(act) ?? `${getNullableApId(activity)}#${i}`; + try { - results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]); + const result = await this.performOneActivity(actor, act, resolver); + results.push([id, result]); } catch (err) { if (err instanceof Error || typeof err === 'string') { - this.logger.error(err); + this.logger.error(`Unhandled error in activity ${id}:`, err); } else { throw err; } @@ -136,7 +151,8 @@ export class ApInboxService { if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { setImmediate(() => { // 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない - this.apPersonService.updatePerson(actor.uri); + this.apPersonService.updatePerson(actor.uri) + .catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`)); }); } } @@ -209,6 +225,10 @@ export class ApInboxService { const note = await this.apNoteService.resolveNote(object, { resolver }); if (!note) return `skip: target note not found ${targetUri}`; + if (note.userHost == null && note.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot react to local-only note'); + } + await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); try { @@ -238,7 +258,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(err => { - this.logger.error(`Resolution failed: ${err}`); + this.logger.error(`Resolution failed: ${renderInlineError(err)}`); throw err; }); @@ -310,18 +330,19 @@ export class ApInboxService { const targetUri = getApId(activityObject); if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; - const target = await resolver.resolve(activityObject).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + const target = await resolver.secureResolve(activityObject, uri).catch(e => { + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); if (isPost(target)) return await this.announceNote(actor, activity, target); + if (isActivity(target)) return await this.announceActivity(activity, target, resolver); return `skip: unknown object type ${getApType(target)}`; } @bindThis - private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise { + private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost & IObjectWithId, resolver?: Resolver): Promise { const uri = getApId(activity); if (actor.isSuspended) { @@ -341,23 +362,17 @@ export class ApInboxService { } // Announce対象をresolve - let renote; - try { - renote = await this.apNoteService.resolveNote(target, { resolver }); - if (renote == null) return 'announce target is null'; - } catch (err) { - // 対象が4xxならスキップ - if (err instanceof StatusError) { - if (!err.isRetryable) { - return `skip: ignored announce target ${target.id} - ${err.statusCode}`; - } - return `Error in announce target ${target.id} - ${err.statusCode}`; - } - throw err; + // The target ID is verified by secureResolve, so we know it shares host authority with the actor who sent it. + // This means we can pass that ID to resolveNote and avoid an extra fetch, which will fail if the note is private. + const renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) }); + if (renote == null) return 'announce target is null'; + + if (!await this.noteEntityService.isVisibleForMe(renote, actor.id, { me: actor })) { + return 'skip: invalid actor for this activity'; } - if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { - return 'skip: invalid actor for this activity'; + if (renote.userHost == null && renote.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot renote a local-only note'); } this.logger.info(`Creating the (Re)Note: ${uri}`); @@ -383,6 +398,65 @@ export class ApInboxService { } } + private async announceActivity(announce: IAnnounce, activity: IActivity & IObjectWithId, resolver: Resolver): Promise { + // Since this is a new activity, we need to get a new actor. + const actorId = getApId(activity.actor); + const actor = await this.apPersonService.resolvePerson(actorId, resolver); + + // Ignore announce of our own activities + // 1. No URI/host on an MiUser == local user + // 2. Local URI on activity == local activity + if (!actor.uri || !actor.host || this.utilityService.isUriLocal(activity.id)) { + throw new Bull.UnrecoverableError(`Cannot announce a local activity: ${activity.id} (from ${announce.id})`); + } + + // Make sure that actor matches activity host. + // Activity host is already verified by resolver when fetching the activity, so that is the source of truth. + const actorHost = this.utilityService.punyHostPSLDomain(actor.uri); + const activityHost = this.utilityService.punyHostPSLDomain(activity.id); + if (actorHost !== activityHost) { + throw new Bull.UnrecoverableError(`Actor host ${actorHost} does not activity host ${activityHost} in activity ${activity.id} (from ${announce.id})`); + } + + // Update stats (adapted from InboxProcessorService) + this.federationChart.inbox(actor.host).then(); + process.nextTick(async () => { + const i = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(actor.host) + : this.federatedInstanceService.fetch(actor.host)); + + if (i == null) return; + + this.updateInstanceQueue.enqueue(i.id, { + latestRequestReceivedAt: new Date(), + shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding', + }); + + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.requestReceived(i.host).then(); + } + + this.fetchInstanceMetadataService.fetchInstanceMetadata(i).then(); + }); + + // Process it! + return await this.performOneActivity(actor, activity, resolver) + .finally(() => { + // Update user (adapted from performActivity) + if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + setImmediate(() => { + // Don't re-use the resolver, or it may throw recursion errors. + // Instead, create a new resolver with an appropriately-reduced recursion limit. + const subResolver = this.apResolverService.createResolver({ + recursionLimit: resolver.getRecursionLimit() - resolver.getHistory().length, + }); + this.apPersonService.updatePerson(actor.uri, subResolver) + .catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`)); + }); + } + }); + } + @bindThis private async block(actor: MiRemoteUser, activity: IBlock): Promise { // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず @@ -402,7 +476,7 @@ export class ApInboxService { } @bindThis - private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver): Promise { + private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver, silent = false): Promise { const uri = getApId(activity); this.logger.info(`Create: ${uri}`); @@ -432,12 +506,12 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activityObject).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); if (isPost(object)) { - await this.createNote(resolver, actor, object, false); + await this.createNote(resolver, actor, object, silent); } else { return `skip: Unsupported type for Create: ${getApType(object)} ${getNullableApId(object)}`; } @@ -469,12 +543,6 @@ export class ApInboxService { await this.apNoteService.createNote(note, actor, resolver, silent); return 'ok'; - } catch (err) { - if (err instanceof StatusError && !err.isRetryable) { - return `skip: ${err.statusCode}`; - } else { - throw err; - } } finally { unlock(); } @@ -530,19 +598,12 @@ export class ApInboxService { 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'; + if (!(await this.usersRepository.update({ id: actor.id, isDeleted: false }, { isDeleted: true })).affected) { + return 'skip: already deleted or actor not found'; } const job = await this.queueService.createDeleteAccountJob(actor); - await this.usersRepository.update(actor.id, { - isDeleted: true, - }); - this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id }); return `ok: queued ${job.name} ${job.id}`; @@ -614,7 +675,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); @@ -686,7 +747,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); @@ -707,12 +768,7 @@ export class ApInboxService { return 'skip: follower not found'; } - const isFollowing = await this.followingsRepository.exists({ - where: { - followerId: follower.id, - followeeId: actor.id, - }, - }); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(actor.id)); if (isFollowing) { await this.userFollowingService.unfollow(follower, actor); @@ -771,12 +827,7 @@ export class ApInboxService { }, }); - const isFollowing = await this.followingsRepository.exists({ - where: { - followerId: actor.id, - followeeId: followee.id, - }, - }); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(actor.id).then(f => f.has(followee.id)); if (requestExist) { await this.userFollowingService.cancelFollowRequest(followee, actor); @@ -818,7 +869,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); @@ -828,7 +879,7 @@ export class ApInboxService { } else if (getApType(object) === 'Question') { // If we get an Update(Question) for a note that doesn't exist, then create it instead if (!await this.apNoteService.hasNote(object)) { - return await this.create(actor, activity, resolver); + return await this.create(actor, activity, resolver, true); } await this.apQuestionService.updateQuestion(object, actor, resolver); @@ -836,7 +887,7 @@ export class ApInboxService { } else if (isPost(object)) { // If we get an Update(Note) for a note that doesn't exist, then create it instead if (!await this.apNoteService.hasNote(object)) { - return await this.create(actor, activity, resolver); + return await this.create(actor, activity, resolver, true); } await this.apNoteService.updateNote(object, actor, resolver); diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index 318710fa93..ddb6461746 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -4,8 +4,8 @@ */ import { Injectable } from '@nestjs/common'; -import * as mfm from '@transfem-org/sfm-js'; -import { MfmService } from '@/core/MfmService.js'; +import * as mfm from 'mfm-js'; +import { MfmService, Appender } from '@/core/MfmService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { extractApHashtagObjects } from './models/tag.js'; @@ -25,17 +25,17 @@ export class ApMfmService { } @bindThis - public getNoteHtml(note: Pick, apAppend?: string) { + public getNoteHtml(note: Pick, additionalAppender: Appender[] = []) { let noMisskeyContent = false; - const srcMfm = (note.text ?? '') + (apAppend ?? ''); + const srcMfm = (note.text ?? ''); const parsed = mfm.parse(srcMfm); - if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { + if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { noMisskeyContent = true; } - const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : []); + const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : [], additionalAppender); return { content, diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 44eb029a35..623e7002cd 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -6,8 +6,9 @@ import { createPublicKey, randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import { UnrecoverableError } from 'bullmq'; +import { Element, Text } from 'domhandler'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; @@ -20,7 +21,7 @@ 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 { MfmService, type Appender } 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'; @@ -29,10 +30,14 @@ import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { IdService } from '@/core/IdService.js'; import { appendContentWarning } from '@/misc/append-content-warning.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; -import { getApId } from './type.js'; +import { getApId, ILink, IOrderedCollection, IOrderedCollectionPage } from './type.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'; @Injectable() @@ -70,6 +75,9 @@ export class ApRendererService { private apMfmService: ApMfmService, private mfmService: MfmService, private idService: IdService, + private readonly queryService: QueryService, + private utilityService: UtilityService, + private readonly cacheService: CacheService, ) { } @@ -227,7 +235,7 @@ export class ApRendererService { */ @bindThis public async renderFollowUser(id: MiUser['id']): Promise { - const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser; + const user = await this.cacheService.findUserById(id) as MiPartialLocalUser | MiPartialRemoteUser; return this.userEntityService.getUserUri(user); } @@ -264,6 +272,49 @@ export class ApRendererService { }; } + @bindThis + public renderIdenticon(user: MiLocalUser): IApImage { + return { + type: 'Image', + url: this.userEntityService.getIdenticonUrl(user), + sensitive: false, + name: null, + }; + } + + @bindThis + public renderSystemAvatar(user: MiLocalUser): IApImage { + if (this.meta.iconUrl == null) return this.renderIdenticon(user); + return { + type: 'Image', + url: this.meta.iconUrl, + sensitive: false, + name: null, + }; + } + + @bindThis + public renderSystemBanner(): IApImage | null { + if (this.meta.bannerUrl == null) return null; + return { + type: 'Image', + url: this.meta.bannerUrl, + sensitive: false, + name: null, + }; + } + + @bindThis + public renderSystemBackground(): IApImage | null { + if (this.meta.backgroundImageUrl == null) return null; + return { + type: 'Image', + url: this.meta.backgroundImageUrl, + sensitive: false, + name: null, + }; + } + @bindThis public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey { return { @@ -354,7 +405,7 @@ export class ApRendererService { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); if (inReplyToNote != null) { - const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); + const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId); if (inReplyToUser) { if (inReplyToNote.uri) { @@ -372,9 +423,9 @@ export class ApRendererService { inReplyTo = null; } - let quote; + let quote: string | undefined = undefined; - if (note.renoteId) { + if (isRenote(note) && isQuote(note)) { const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); if (renote) { @@ -388,13 +439,16 @@ export class ApRendererService { let to: string[] = []; let cc: string[] = []; + let isPublic = false; if (note.visibility === 'public') { to = ['https://www.w3.org/ns/activitystreams#Public']; cc = [`${attributedTo}/followers`].concat(mentions); + isPublic = true; } else if (note.visibility === 'home') { to = [`${attributedTo}/followers`]; cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); + isPublic = true; } else if (note.visibility === 'followers') { to = [`${attributedTo}/followers`]; cc = mentions; @@ -418,10 +472,26 @@ export class ApRendererService { poll = await this.pollsRepository.findOneBy({ noteId: note.id }); } - let apAppend = ''; + const apAppend: Appender[] = []; if (quote) { - apAppend += `\n\nRE: ${quote}`; + // Append quote link as `

RE: ...` + // the claas name `quote-inline` is used in non-misskey clients for styling quote notes. + // For compatibility, the span part should be kept as possible. + apAppend.push((doc, body) => { + body.childNodes.push(new Element('br', {})); + body.childNodes.push(new Element('br', {})); + const span = new Element('span', { + class: 'quote-inline', + }); + span.childNodes.push(new Text('RE: ')); + const link = new Element('a', { + href: quote, + }); + link.childNodes.push(new Text(quote)); + span.childNodes.push(link); + body.childNodes.push(span); + }); } let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; @@ -436,12 +506,22 @@ export class ApRendererService { const emojis = await this.getEmojis(note.emojis); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); - const tag = [ + const tag: IObject[] = [ ...hashtagTags, ...mentionTags, ...apemojis, ]; + // https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md + if (quote) { + tag.push({ + type: 'Link', + mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + rel: 'https://misskey-hub.net/ns#_misskey_quote', + href: quote, + } satisfies ILink); + } + const asPoll = poll ? { type: 'Question', [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, @@ -455,12 +535,17 @@ export class ApRendererService { })), } as const : {}; + // Render the outer replies collection wrapper, which contains the count but not the actual URLs. + // This saves one hop (request) when de-referencing the replies. + const replies = isPublic ? await this.renderRepliesCollection(note.id) : undefined; + return { id: `${this.config.url}/notes/${note.id}`, type: 'Note', attributedTo, summary: summary ?? undefined, content: content ?? undefined, + updated: note.updatedAt?.toISOString() ?? undefined, _misskey_content: text, source: { content: text, @@ -469,10 +554,13 @@ export class ApRendererService { _misskey_quote: quote, quoteUrl: quote, quoteUri: quote, + // https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md + quote: quote, published: this.idService.parse(note.id).date.toISOString(), to, cc, inReplyTo, + replies, attachment: files.map(x => this.renderDocument(x)), sensitive: note.cw != null || files.some(file => file.isSensitive), tag, @@ -530,9 +618,9 @@ export class ApRendererService { _misskey_requireSigninToViewContents: user.requireSigninToViewContents, _misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore, _misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore, - icon: avatar ? this.renderImage(avatar) : null, - image: banner ? this.renderImage(banner) : null, - backgroundUrl: background ? this.renderImage(background) : null, + icon: avatar ? this.renderImage(avatar) : isSystem ? this.renderSystemAvatar(user) : this.renderIdenticon(user), + image: banner ? this.renderImage(banner) : isSystem ? this.renderSystemBanner() : null, + backgroundUrl: background ? this.renderImage(background) : isSystem ? this.renderSystemBackground() : null, tag, manuallyApprovesFollowers: user.isLocked, discoverable: user.isExplorable, @@ -544,6 +632,7 @@ export class ApRendererService { enableRss: user.enableRss, speakAsCat: user.speakAsCat, attachment: attachment.length ? attachment : undefined, + attributionDomains: user.attributionDomains, }; if (user.movedToUri) { @@ -569,6 +658,38 @@ export class ApRendererService { return person; } + @bindThis + public async renderPersonRedacted(user: MiLocalUser) { + const id = this.userEntityService.genLocalUserUri(user.id); + const isSystem = user.username.includes('.'); + + const keypair = await this.userKeypairService.getUserKeypair(user.id); + + return { + // Basic federation metadata + type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + sharedInbox: `${this.config.url}/inbox`, + endpoints: { sharedInbox: `${this.config.url}/inbox` }, + url: `${this.config.url}/@${user.username}`, + preferredUsername: user.username, + publicKey: this.renderKey(user, keypair, '#main-key'), + + // Privacy settings + _misskey_requireSigninToViewContents: user.requireSigninToViewContents, + _misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore, + _misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore, + manuallyApprovesFollowers: user.isLocked, + discoverable: user.isExplorable, + hideOnlineStatus: user.hideOnlineStatus, + noindex: user.noindex, + indexable: !user.noindex, + enableRss: user.enableRss, + }; + } + @bindThis public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion { return { @@ -616,7 +737,7 @@ export class ApRendererService { @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; + const id = typeof object !== 'string' && typeof object.id === 'string' && this.utilityService.isUriLocal(object.id) ? `${object.id}/undo` : undefined; return { type: 'Undo', @@ -639,148 +760,6 @@ export class ApRendererService { }; } - @bindThis - public async renderUpNote(note: MiNote, author: MiUser, 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 inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - - if (inReplyToUser) { - if (inReplyToNote.uri) { - inReplyTo = inReplyToNote.uri; - } else { - if (dive) { - inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, 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 apAppend = ''; - - if (quote) { - apAppend += `\n\nRE: ${quote}`; - } - - let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - - // Apply mandatory CW, if applicable - if (author.mandatoryCW) { - summary = appendContentWarning(summary, author.mandatoryCW); - } - - const { content } = this.apMfmService.getNoteHtml(note, apAppend); - - 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', - [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 { @@ -820,9 +799,7 @@ export class ApRendererService { const keypair = await this.userKeypairService.getUserKeypair(user.id); - const jsonLd = this.jsonLdService.use(); - jsonLd.debug = false; - activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); + activity = await this.jsonLdService.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); return activity; } @@ -875,6 +852,88 @@ export class ApRendererService { return page; } + /** + * Renders the reply collection wrapper object for a note + * @param noteId Note whose reply collection to render. + */ + @bindThis + public async renderRepliesCollection(noteId: string): Promise { + const replyCount = await this.notesRepository.countBy({ + replyId: noteId, + visibility: In(['public', 'home']), + localOnly: false, + }); + + return { + type: 'OrderedCollection', + id: `${this.config.url}/notes/${noteId}/replies`, + first: `${this.config.url}/notes/${noteId}/replies?page=true`, + totalItems: replyCount, + }; + } + + /** + * Renders a page of the replies collection for a note + * @param noteId Return notes that are inReplyTo this value. + * @param untilId If set, return only notes that are *older* than this value. + */ + @bindThis + public async renderRepliesCollectionPage(noteId: string, untilId: string | undefined): Promise { + const replyCount = await this.notesRepository.countBy({ + replyId: noteId, + visibility: In(['public', 'home']), + localOnly: false, + }); + + const limit = 50; + const results = await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), undefined, untilId) + .andWhere({ + replyId: noteId, + visibility: In(['public', 'home']), + localOnly: false, + }) + .select(['note.id', 'note.uri']) + .limit(limit) + .getRawMany<{ note_id: string, note_uri: string | null }>(); + + const hasNextPage = results.length >= limit; + const baseId = `${this.config.url}/notes/${noteId}/replies?page=true`; + + return { + type: 'OrderedCollectionPage', + id: untilId == null ? baseId : `${baseId}&until_id=${untilId}`, + partOf: `${this.config.url}/notes/${noteId}/replies`, + first: baseId, + next: hasNextPage ? `${baseId}&until_id=${results.at(-1)?.note_id}` : undefined, + totalItems: replyCount, + orderedItems: results.map(r => { + // Remote notes have a URI, local have just an ID. + return r.note_uri ?? `${this.config.url}/notes/${r.note_id}`; + }), + }; + } + + @bindThis + public async renderNoteOrRenoteActivity(note: MiNote, user: MiUser, hint?: { renote?: MiNote | null }) { + if (note.localOnly) return null; + + if (isPureRenote(note)) { + const renote = hint?.renote ?? note.renote ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId }); + const apAnnounce = this.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note); + return this.addContext(apAnnounce); + } + + const apNote = await this.renderNote(note, user, false); + + if (note.updatedAt != null) { + const apUpdate = this.renderUpdate(apNote, user); + return this.addContext(apUpdate); + } else { + const apCreate = this.renderCreate(apNote, note); + return this.addContext(apCreate); + } + } + @bindThis private async getEmojis(names: string[]): Promise { if (names.length === 0) return []; diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index b63d4eb2ab..e4db9b237c 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -6,7 +6,7 @@ import * as crypto from 'node:crypto'; import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { Window } from 'happy-dom'; +import { load as cheerio } from 'cheerio/slim'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; @@ -17,7 +17,9 @@ 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'; -import type { IObject } from './type.js'; +import type { IObject, IObjectWithId } from './type.js'; +import type { Cheerio, CheerioAPI } from 'cheerio/slim'; +import type { AnyNode } from 'domhandler'; type Request = { url: string; @@ -155,6 +157,8 @@ export class ApRequestService { @bindThis public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise { + this.apUtilityService.assertApUrl(url); + const body = typeof object === 'string' ? object : JSON.stringify(object); const keypair = await this.userKeypairService.getUserKeypair(user.id); @@ -182,10 +186,13 @@ export class ApRequestService { * Get AP object with http-signature * @param user http-signature user * @param url URL to fetch - * @param followAlternate + * @param allowAnonymous If a fetched object lacks an ID, then it will be auto-generated from the final URL. (default: false) + * @param followAlternate Whether to resolve HTML responses to their referenced canonical AP endpoint. (default: true) */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise { + public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise { + this.apUtilityService.assertApUrl(url); + const _followAlternate = followAlternate ?? true; const keypair = await this.userKeypairService.getUserKeypair(user.id); @@ -214,53 +221,33 @@ export class ApRequestService { (contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true ) { - const html = await res.text(); - const { window, happyDOM } = new Window({ - settings: { - disableJavaScriptEvaluation: true, - disableJavaScriptFileLoading: true, - disableCSSFileLoading: true, - disableComputedStyleRendering: true, - handleDisabledFileLoadingAsSuccess: true, - navigation: { - disableMainFrameNavigation: true, - disableChildFrameNavigation: true, - disableChildPageNavigation: true, - disableFallbackToSetURL: true, - }, - timer: { - maxTimeout: 0, - maxIntervalTime: 0, - maxIntervalIterations: 0, - }, - }, - }); - const document = window.document; + let alternate: Cheerio | null; try { - document.documentElement.innerHTML = html; + const html = await res.text(); + const document = cheerio(html); // Search for any matching value in priority order: // 1. Type=AP > Type=none > Type=anything // 2. Alternate > Canonical // 3. Page order (fallback) - const alternate = - document.querySelector('head > link[href][rel="alternate"][type="application/activity+json"]') ?? - document.querySelector('head > link[href][rel="canonical"][type="application/activity+json"]') ?? - document.querySelector('head > link[href][rel="alternate"]:not([type])') ?? - document.querySelector('head > link[href][rel="canonical"]:not([type])') ?? - document.querySelector('head > link[href][rel="alternate"]') ?? - document.querySelector('head > link[href][rel="canonical"]'); - - if (alternate) { - const href = alternate.getAttribute('href'); - if (href && this.apUtilityService.haveSameAuthority(url, href)) { - return await this.signedGet(href, user, false); - } - } - } catch (e) { + alternate = selectFirst(document, [ + 'head > link[href][rel="alternate"][type="application/activity+json"]', + 'head > link[href][rel="canonical"][type="application/activity+json"]', + 'head > link[href][rel="alternate"]:not([type])', + 'head > link[href][rel="canonical"]:not([type])', + 'head > link[href][rel="alternate"]', + 'head > link[href][rel="canonical"]', + ]); + } catch { // something went wrong parsing the HTML, ignore the whole thing - } finally { - happyDOM.close().catch(err => {}); + alternate = null; + } + + if (alternate) { + const href = alternate.attr('href'); + if (href && this.apUtilityService.haveSameAuthority(url, href)) { + return await this.signedGet(href, user, allowAnonymous, false); + } } } //#endregion @@ -271,8 +258,23 @@ export class ApRequestService { // Make sure the object ID matches the final URL (which is where it actually exists). // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. - this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + if (allowAnonymous && activity.id == null) { + activity.id = res.url; + } else { + this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + } - return activity; + return activity as IObjectWithId; } } + +function selectFirst($: CheerioAPI, selectors: string[]): Cheerio | null { + for (const selector of selectors) { + const selection = $(selector); + if (selection.length > 0) { + return selection; + } + } + + return null; +} diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index f9ccf10fa7..d53e265d36 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -5,8 +5,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; +import promiseLimit from 'promise-limit'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -16,14 +16,18 @@ import { bindThis } from '@/decorators.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { fromTuple } from '@/misc/from-tuple.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApLogService, calculateDurationSince, extractObjectContext } from '@/core/ApLogService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; -import { getApId, getNullableApId, isCollectionOrOrderedCollection } from './type.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { toArray } from '@/misc/prelude/array.js'; +import { isPureRenote } from '@/misc/is-renote.js'; +import { CacheService } from '@/core/CacheService.js'; +import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } 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'; +import type { IObject, ApObject, IAnonymousObject } from './type.js'; export class Resolver { private history: Set; @@ -39,7 +43,7 @@ export class Resolver { private noteReactionsRepository: NoteReactionsRepository, private followRequestsRepository: FollowRequestsRepository, private utilityService: UtilityService, - private instanceActorService: InstanceActorService, + private systemAccountService: SystemAccountService, private apRequestService: ApRequestService, private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, @@ -47,6 +51,7 @@ export class Resolver { private loggerService: LoggerService, private readonly apLogService: ApLogService, private readonly apUtilityService: ApUtilityService, + private readonly cacheService: CacheService, private recursionLimit = 256, ) { this.history = new Set(); @@ -63,37 +68,184 @@ export class Resolver { return this.recursionLimit; } + public async resolveCollection(value: string | IObjectWithId, allowAnonymous?: boolean, sentFromUri?: string): Promise; + public async resolveCollection(value: string | IObject, allowAnonymous: boolean | undefined, sentFromUri: string): Promise; + public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise; @bindThis - public async resolveCollection(value: string | IObject): Promise { + public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise { const collection = typeof value === 'string' - ? await this.resolve(value) - : value; + ? sentFromUri + ? await this.secureResolve(value, sentFromUri, allowAnonymous) + : await this.resolve(value, allowAnonymous) + : value; // TODO try and remove this eventually, as it's a major security foot-gun if (isCollectionOrOrderedCollection(collection)) { return collection; } else { - throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`); + throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `collection ${getApId(value)} has unsupported type: ${collection.type}`); } } + public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise; + public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise; + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise; + /** + * Recursively resolves items from a collection. + * Stops when reaching the resolution limit or an optional item limit - whichever is lower. + * This method supports Collection, OrderedCollection, and individual pages of either type. + * Malformed collections (mixing Ordered and un-Ordered types) are also supported. + * @param collection Collection to resolve from - can be a URL or object of any supported collection type. + * @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit. + * @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID. + * @param concurrency Maximum number of items to resolve at once. (default: 4) + */ @bindThis - public async resolve(value: string | IObject | [string | IObject]): Promise { - // eslint-disable-next-line no-param-reassign + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise { + const resolvedItems: IObject[] = []; + + // This is pulled up to avoid code duplication below + const iterate = async(items: ApObject, current: AnyCollection) => { + const sentFrom = current.id; + const itemArr = toArray(items); + const itemLimit = limit ?? Number.MAX_SAFE_INTEGER; + const allowAnonymous = allowAnonymousItems ?? false; + await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems); + }; + + let current: AnyCollection | null = await this.resolveCollection(collection); + do { + // Iterate all items in the current page + if (current.items) { + await iterate(current.items, current); + } + if (current.orderedItems) { + await iterate(current.orderedItems, current); + } + + if (this.history.size >= this.recursionLimit) { + // Stop when we reach the fetch limit + current = null; + } else if (limit != null && resolvedItems.length >= limit) { + // Stop when we reach the item limit + current = null; + } else if (isCollection(current) || isOrderedCollection(current)) { + // Continue to first page + current = current.first ? await this.resolveCollection(current.first, true, current.id) : null; + } else if (isCollectionPage(current) || isOrderedCollectionPage(current)) { + // Continue to next page + current = current.next ? await this.resolveCollection(current.next, true, current.id) : null; + } else { + // Stop in all other conditions + current = null; + } + } while (current != null); + + return resolvedItems; + } + + private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise { + const recursionLimit = this.recursionLimit - this.history.size; + const batchLimit = Math.min(source.length, recursionLimit, itemLimit); + + const limiter = promiseLimit(concurrency); + const batch = await Promise.all(source + .slice(0, batchLimit) + .map(item => limiter(async () => { + if (sentFrom) { + // Use secureResolve to avoid re-fetching items that were included inline. + return await this.secureResolve(item, sentFrom, allowAnonymousItems); + } else if (allowAnonymousItems) { + return await this.resolveAnonymous(item); + } else { + // ID is required if we have neither sentFrom not allowAnonymousItems + const id = getApId(item); + return await this.resolve(id); + } + }))); + + destination.push(...batch); + }; + + /** + * Securely resolves an AP object or URL that has been sent from another instance. + * An input object is trusted if and only if its ID matches the authority of sentFromUri. + * In all other cases, the object is re-fetched from remote by input string or object ID. + * @param input The input object or URL to resolve + * @param sentFromUri The URL where this object originated. This MUST be accurate - all security checks depend on this value! + * @param allowAnonymous If true, anonymous objects are allowed and will have their ID set to sentFromUri. If false (default) then anonymous objects will be rejected with an error. + */ + @bindThis + public async secureResolve(input: string | IObject | [string | IObject], sentFromUri: string, allowAnonymous?: boolean): Promise { + // Unpack arrays to get the value element. + const value = fromTuple(input); + + // If anonymous input is allowed, then any object is automatically valid if we set the ID. + // We can short-circuit here and avoid un-necessary checks. + if (allowAnonymous && typeof(value) === 'object' && value.id == null) { + value.id = sentFromUri; + return value as IObjectWithId; + } + + // This ensures the input has a string ID, protecting against type confusion and rejecting anonymous objects. + const id = getApId(value, sentFromUri); + + // Check if we can use the provided object as-is. + // Our security requires that the object ID matches the host authority that sent it, otherwise it can't be trusted. + // A mismatch isn't necessarily malicious, it just means we can't use the object we were given. + if (typeof(value) === 'object' && this.apUtilityService.haveSameAuthority(id, sentFromUri)) { + return value as IObjectWithId; + } + + // If the checks didn't pass, then we must fetch the object and use that. + return await this.resolve(id, allowAnonymous); + } + + /** + * Resolves an anonymous object. + * The returned value will not have any ID present. + * If one is provided in the response, it will be removed automatically. + */ + @bindThis + public async resolveAnonymous(value: string | IObject | [string | IObject]): Promise { value = fromTuple(value); + const object = await this.resolve(value); + object.id = undefined; + + return object as IAnonymousObject; + } + + public async resolve(value: string | [string], allowAnonymous?: boolean): Promise; + public async resolve(value: string | IObjectWithId | [string | IObjectWithId], allowAnonymous?: boolean): Promise; + public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise; + /** + * Resolves a URL or object to an AP object. + * Tuples are expanded to their first element before anything else, and non-string inputs are returned as-is. + * Otherwise, the string URL is fetched and validated to represent a valid ActivityPub object. + * @param value The input value to resolve + * @param allowAnonymous Determines what to do if a response object lacks an ID field. If false (default), then an exception is thrown. If true, then the ID is populated from the final response URL. + */ + @bindThis + public async resolve(value: string | IObject | [string | IObject], allowAnonymous = false): Promise { + value = fromTuple(value); + + // TODO try and remove this eventually, as it's a major security foot-gun if (typeof value !== 'string') { return value; } const host = this.utilityService.extractDbHost(value); if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) { - return await this._resolveLogged(value, host); + return await this._resolveLogged(value, host, allowAnonymous); } else { - return await this._resolve(value, host); + return await this._resolve(value, host, allowAnonymous); } } - private async _resolveLogged(requestUri: string, host: string): Promise { + private async _resolveLogged(requestUri: string, host: string, allowAnonymous: boolean): Promise { const startTime = process.hrtime.bigint(); const log = await this.apLogService.createFetchLog({ @@ -102,7 +254,7 @@ export class Resolver { }); try { - const result = await this._resolve(requestUri, host, log); + const result = await this._resolve(requestUri, host, allowAnonymous, log); log.accepted = true; log.result = 'ok'; @@ -122,39 +274,39 @@ export class Resolver { } } - private async _resolve(value: string, host: string, log?: SkApFetchLog): Promise { + private async _resolve(value: string, host: string, allowAnonymous: boolean, log?: SkApFetchLog): Promise { 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 IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `cannot resolve URL with fragment: ${value}`); + throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `failed to resolve ${value}: URL contains fragment`); } if (this.history.has(value)) { - throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', `cannot resolve already resolved URL: ${value}`); + throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', `failed to resolve ${value}: recursive resolution blocked`); } if (this.history.size > this.recursionLimit) { - throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `hit recursion limit: ${value}`); + throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `failed to resolve ${value}: hit recursion limit`); } this.history.add(value); if (this.utilityService.isSelfHost(host)) { - return await this.resolveLocal(value); + return await this.resolveLocal(value) as IObjectWithId; } if (!this.utilityService.isFederationAllowedHost(host)) { - throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', `cannot fetch AP object ${value}: blocked instance ${host}`); + throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', `failed to resolve ${value}: instance ${host} is blocked`); } if (this.config.signToActivityPubGet && !this.user) { - this.user = await this.instanceActorService.getInstanceActor(); + this.user = await this.systemAccountService.fetch('actor'); } const object = (this.user - ? await this.apRequestService.signedGet(value, this.user) as IObject - : await this.httpRequestService.getActivityJson(value)) as IObject; + ? await this.apRequestService.signedGet(value, this.user, allowAnonymous) + : await this.httpRequestService.getActivityJson(value, false, allowAnonymous)); if (log) { const { object: objectOnly, context, contextHash } = extractObjectContext(object); @@ -175,12 +327,12 @@ export class Resolver { !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : object['@context'] !== 'https://www.w3.org/ns/activitystreams' ) { - throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `invalid AP object ${value}: does not have ActivityStreams context`); + throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `failed to resolve ${value}: response does not have ActivityStreams context`); } // The object ID is already validated to match the final URL's authority by signedGet / getActivityJson. // We only need to validate that it also matches the original URL's authority, in case of redirects. - const objectId = getApId(object); + const objectId = getApId(object, value); // We allow some limited cross-domain redirects, which means the host may have changed during fetch. // Additional checks are needed to validate the scope of cross-domain redirects. @@ -191,64 +343,65 @@ export class Resolver { // Check if the redirect bounce from [allowed domain] to [blocked domain]. if (!this.utilityService.isFederationAllowedHost(finalHost)) { - throw new IdentifiableError('0a72bf24-2d9b-4f1d-886b-15aaa31adeda', `cannot fetch AP object ${value}: redirected to blocked instance ${finalHost}`); + throw new IdentifiableError('0a72bf24-2d9b-4f1d-886b-15aaa31adeda', `failed to resolve ${value}: redirected to blocked instance ${finalHost}`); } } return object; } + // TODO try to remove this, as it creates a large attack surface @bindThis - private resolveLocal(url: string): Promise { + private resolveLocal(url: string): Promise { const parsed = this.apDbResolverService.parseUri(url); - if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `resolveLocal - not a local URL: ${url}`); + if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `failed to resolve local ${url}: not a local URL`); switch (parsed.type) { case 'notes': - return this.notesRepository.findOneByOrFail({ id: parsed.id }) + return this.notesRepository.findOneOrFail({ where: { id: parsed.id, userHost: IsNull() }, relations: { user: true, renote: true } }) .then(async note => { - const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); + const author = note.user ?? await this.cacheService.findUserById(note.userId); 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, author), note)); + return await this.apRendererService.renderNoteOrRenoteActivity(note, author); + } else if (!isPureRenote(note)) { + const apNote = await this.apRendererService.renderNote(note, author); + return this.apRendererService.addContext(apNote); } else { - return this.apRendererService.renderNote(note, author); + throw new IdentifiableError('732c2633-3395-4d51-a9b7-c7084774e3e7', `Failed to resolve local ${url}: cannot resolve a boost as note`); } - }); + }) as Promise; case 'users': - return this.usersRepository.findOneByOrFail({ id: parsed.id }) + return this.cacheService.findLocalUserById(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 }), + this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() }), + this.pollsRepository.findOneByOrFail({ noteId: parsed.id, userHost: IsNull() }), ]) - .then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)); + .then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)) as Promise; case 'likes': - return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(async reaction => - this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null }))); + return this.noteReactionsRepository.findOneOrFail({ where: { id: parsed.id }, relations: { user: true } }).then(async reaction => { + if (reaction.user?.host != null) { + throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `failed to resolve local ${url}: not a local reaction`); + } + return 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 IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `resolveLocal - invalid follow request ID ${parsed.id}: ${url}`); + if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: 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()), - }), + this.cacheService.findLocalUserById(followRequest.followerId), + this.cacheService.findLocalUserById(followRequest.followeeId), ]); if (follower == null || followee == null) { - throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `resolveLocal - follower or followee does not exist: ${url}`); + throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: 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 IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled: ${url}`); + throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `failed to resolve local ${url}: unsupported type ${parsed.type}`); } } } @@ -278,7 +431,7 @@ export class ApResolverService { private followRequestsRepository: FollowRequestsRepository, private utilityService: UtilityService, - private instanceActorService: InstanceActorService, + private systemAccountService: SystemAccountService, private apRequestService: ApRequestService, private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, @@ -286,11 +439,15 @@ export class ApResolverService { private loggerService: LoggerService, private readonly apLogService: ApLogService, private readonly apUtilityService: ApUtilityService, + private readonly cacheService: CacheService, ) { } @bindThis - public createResolver(): Resolver { + public createResolver(opts?: { + // Override the recursion limit + recursionLimit?: number, + }): Resolver { return new Resolver( this.config, this.meta, @@ -300,7 +457,7 @@ export class ApResolverService { this.noteReactionsRepository, this.followRequestsRepository, this.utilityService, - this.instanceActorService, + this.systemAccountService, this.apRequestService, this.httpRequestService, this.apRendererService, @@ -308,6 +465,8 @@ export class ApResolverService { this.loggerService, this.apLogService, this.apUtilityService, + this.cacheService, + opts?.recursionLimit, ); } } diff --git a/packages/backend/src/core/activitypub/ApUtilityService.ts b/packages/backend/src/core/activitypub/ApUtilityService.ts index ae6e4997e4..227dc3b9b3 100644 --- a/packages/backend/src/core/activitypub/ApUtilityService.ts +++ b/packages/backend/src/core/activitypub/ApUtilityService.ts @@ -24,7 +24,7 @@ export class ApUtilityService { public assertIdMatchesUrlAuthority(object: IObject, url: string): void { // This throws if the ID is missing or invalid, but that's ok. // Anonymous objects are impossible to verify, so we don't allow fetching them. - const id = getApId(object); + const id = getApId(object, url); // Make sure the object ID matches the final URL (which is where it actually exists). // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. @@ -77,16 +77,42 @@ export class ApUtilityService { return acceptableUrls[0]?.url ?? null; } + /** + * Verifies that a provided URL is in a format acceptable for federation. + * @throws {IdentifiableError} If URL cannot be parsed + * @throws {IdentifiableError} If URL is not HTTPS + */ + public assertApUrl(url: string | URL): void { + // If string, parse and validate + if (typeof(url) === 'string') { + try { + url = new URL(url); + } catch { + throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: not a valid URL`); + } + } + + // Must be HTTPS + if (!this.checkHttps(url)) { + throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`); + } + } + /** * Checks if the URL contains HTTPS. * Additionally, allows HTTP in non-production environments. * Based on check-https.ts. */ - private checkHttps(url: string): boolean { + private checkHttps(url: string | URL): boolean { const isNonProd = this.envService.env.NODE_ENV !== 'production'; - // noinspection HttpUrlsUsage - return url.startsWith('https://') || (url.startsWith('http://') && isNonProd); + try { + const proto = new URL(url).protocol; + return proto === 'https:' || (proto === 'http:' && isNonProd); + } catch { + // Invalid URLs don't "count" as HTTPS + return false; + } } } diff --git a/packages/backend/src/core/activitypub/JsonLdService.ts b/packages/backend/src/core/activitypub/JsonLdService.ts index 9d1e2e06cc..8f150ab201 100644 --- a/packages/backend/src/core/activitypub/JsonLdService.ts +++ b/packages/backend/src/core/activitypub/JsonLdService.ts @@ -8,25 +8,61 @@ import { Injectable } from '@nestjs/common'; import { UnrecoverableError } from 'bullmq'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { StatusError } from '@/misc/status-error.js'; import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js'; import { validateContentTypeSetAsJsonLD } from './misc/validator.js'; -import type { JsonLdDocument } from 'jsonld'; +import type { ContextDefinition, JsonLdDocument } from 'jsonld'; import type { JsonLd as JsonLdObject, RemoteDocument } from 'jsonld/jsonld-spec.js'; +// https://stackoverflow.com/a/66252656 +type RemoveIndex = { + [ K in keyof T as string extends K + ? never + : number extends K + ? never + : symbol extends K + ? never + : K + ] : T[K]; +}; + +export type Document = RemoveIndex; + +export type Signature = { + id?: string; + type: string; + creator: string; + domain?: string; + nonce: string; + created: string; + signatureValue: string; +}; + +export type Signed = T & { + signature: Signature; +}; + +export function isSigned(doc: T): doc is Signed { + return 'signature' in doc && typeof(doc.signature) === 'object'; +} + // RsaSignature2017 implementation is based on https://github.com/transmute-industries/RsaSignature2017 -class JsonLd { - public debug = false; - public preLoad = true; - public loderTimeout = 5000; +@Injectable() +export class JsonLdService { + private readonly logger: Logger; constructor( private httpRequestService: HttpRequestService, + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('json-ld'); } @bindThis - public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise { + public async signRsaSignature2017(data: T, privateKey: string, creator: string, domain?: string, created?: Date): Promise> { const options: { type: string; creator: string; @@ -62,7 +98,7 @@ class JsonLd { } @bindThis - public async verifyRsaSignature2017(data: any, publicKey: string): Promise { + public async verifyRsaSignature2017(data: Signed, publicKey: string): Promise { const toBeSigned = await this.createVerifyData(data, data.signature); const verifier = crypto.createVerify('sha256'); verifier.update(toBeSigned); @@ -70,7 +106,7 @@ class JsonLd { } @bindThis - public async createVerifyData(data: any, options: any): Promise { + public async createVerifyData(data: T, options: Partial): Promise { const transformedOptions = { ...options, '@context': 'https://w3id.org/identity/v1', @@ -80,17 +116,18 @@ class JsonLd { delete transformedOptions['signatureValue']; const canonizedOptions = await this.normalize(transformedOptions); const optionsHash = this.sha256(canonizedOptions.toString()); - const transformedData = { ...data }; + const transformedData = { ...data } as T & { signature?: unknown }; delete transformedData['signature']; const cannonidedData = await this.normalize(transformedData); - if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); + this.logger.debug('cannonidedData', cannonidedData); const documentHash = this.sha256(cannonidedData.toString()); const verifyData = `${optionsHash}${documentHash}`; return verifyData; } @bindThis - public async compact(data: any, context: any = CONTEXT): Promise { + // TODO our default CONTEXT isn't valid for the library, is this a bug? + public async compact(data: Document, context: ContextDefinition = CONTEXT as unknown as ContextDefinition): 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 @@ -100,7 +137,7 @@ class JsonLd { } @bindThis - public async normalize(data: JsonLdDocument): Promise { + public async normalize(data: Document): Promise { const customLoader = this.getLoader(); return (await import('jsonld')).default.normalize(data, { documentLoader: customLoader, @@ -112,9 +149,9 @@ class JsonLd { return async (url: string): Promise => { if (!/^https?:\/\//.test(url)) throw new UnrecoverableError(`Invalid URL: ${url}`); - if (this.preLoad) { + { if (url in PRELOADED_CONTEXTS) { - if (this.debug) console.debug(`HIT: ${url}`); + this.logger.debug(`Preload HIT: ${url}`); return { contextUrl: undefined, document: PRELOADED_CONTEXTS[url], @@ -123,7 +160,7 @@ class JsonLd { } } - if (this.debug) console.debug(`MISS: ${url}`); + this.logger.debug(`Preload MISS: ${url}`); const document = await this.fetchDocument(url); return { contextUrl: undefined, @@ -141,7 +178,6 @@ class JsonLd { headers: { Accept: 'application/ld+json, application/json', }, - timeout: this.loderTimeout, }, { throwErrorWhenResponseNotOk: false, @@ -149,7 +185,7 @@ class JsonLd { }, ).then(res => { if (!res.ok) { - throw new Error(`JSON-LD fetch failed with ${res.status} ${res.statusText}: ${url}`); + throw new StatusError(`failed to fetch JSON-LD from ${url}`, res.status, res.statusText); } else { return res.json(); } @@ -165,16 +201,3 @@ class JsonLd { return hash.digest('hex'); } } - -@Injectable() -export class JsonLdService { - constructor( - private httpRequestService: HttpRequestService, - ) { - } - - @bindThis - public use(): JsonLd { - return new JsonLd(this.httpRequestService); - } -} diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index 5c0b8ffcbb..fa003b1791 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -540,12 +540,20 @@ const extension_context_definition = { quoteUrl: 'as:quoteUrl', fedibird: 'http://fedibird.com/ns#', quoteUri: 'fedibird:quoteUri', + quote: { + '@id': 'https://w3id.org/fep/044f#quote', + '@type': '@id', + }, // Mastodon toot: 'http://joinmastodon.org/ns#', Emoji: 'toot:Emoji', featured: 'toot:featured', discoverable: 'toot:discoverable', indexable: 'toot:indexable', + attributionDomains: { + '@id': 'toot:attributionDomains', + '@type': '@id', + }, // schema schema: 'http://schema.org#', PropertyValue: 'schema:PropertyValue', diff --git a/packages/backend/src/core/activitypub/misc/validator.ts b/packages/backend/src/core/activitypub/misc/validator.ts index 4292b7e0f7..5cd2ddf006 100644 --- a/packages/backend/src/core/activitypub/misc/validator.ts +++ b/packages/backend/src/core/activitypub/misc/validator.ts @@ -3,13 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { IdentifiableError } from '@/misc/identifiable-error.js'; 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(`invalid content type of AP response - no content-type header: ${response.url}`); + throw new IdentifiableError('d09dc850-b76c-4f45-875a-7389339d78b8', `invalid AP response from ${response.url}: no content-type header`, true); } if ( contentType.startsWith('application/activity+json') || @@ -17,7 +18,7 @@ export function validateContentTypeSetAsActivityPub(response: Response): void { ) { return; } - throw new Error(`invalid content type of AP response - content type is not application/activity+json or application/ld+json: ${response.url}`); + throw new IdentifiableError('dc110060-a5f2-461d-808b-39c62702ca64', `invalid AP response from ${response.url}: content type "${contentType}" is not application/activity+json or application/ld+json`); } const plusJsonSuffixRegex = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/; @@ -26,7 +27,7 @@ export function validateContentTypeSetAsJsonLD(response: Response): void { const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); if (contentType === '') { - throw new Error(`invalid content type of JSON LD - no content-type header: ${response.url}`); + throw new IdentifiableError('45793ab7-7648-4886-b503-429f8a0d0f73', `invalid AP response from ${response.url}: no content-type header`, true); } if ( contentType.startsWith('application/ld+json') || @@ -35,5 +36,5 @@ export function validateContentTypeSetAsJsonLD(response: Response): void { ) { return; } - throw new Error(`invalid content type of JSON LD - content type is not application/ld+json or application/json: ${response.url}`); + throw new IdentifiableError('4bf8f36b-4d33-4ac9-ad76-63fa11f354e9', `invalid AP response from ${response.url}: content type "${contentType}" 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 index 423044b985..7a16972ea4 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -18,7 +18,7 @@ import type { Config } from '@/config.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; -import { isDocument, type IObject } from '../type.js'; +import { getNullableApId, isDocument, type IObject } from '../type.js'; @Injectable() export class ApImageService { @@ -48,7 +48,7 @@ export class ApImageService { public async createImage(actor: MiRemoteUser, value: string | IObject): Promise { // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { - throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${actor.uri}`); + throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create image ${getNullableApId(value)}: actor ${actor.id} has been suspended`); } const image = await this.apResolverService.createResolver().resolve(value); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 63f9887a8d..2a28405121 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -26,7 +26,8 @@ import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { isRetryableError } from '@/misc/is-retryable-error.js'; -import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument, isLink } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; import { ApDbResolverService } from '../ApDbResolverService.js'; @@ -95,33 +96,34 @@ export class ApNoteService { actor?: MiRemoteUser, user?: MiRemoteUser, ): Error | null { + this.apUtilityService.assertApUrl(uri); const expectHost = this.utilityService.extractDbHost(uri); const apType = getApType(object); if (apType == null || !validPost.includes(apType)) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${apType ?? 'undefined'}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: invalid object type ${apType ?? 'undefined'}`); } if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: 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 IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed'); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note from ${uri}: published timestamp is malformed'); } if (actor) { const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri; if (attribution !== actor.uri) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`); } if (user && attribution !== user.uri) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated attribution does not match original attribution. updated attribution: ${user.uri}, original attribution: ${attribution}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: updated attribution does not match original attribution. updated attribution: ${user.uri}, original attribution: ${attribution}`); } } @@ -160,7 +162,7 @@ export class ApNoteService { const entryUri = getApId(value); const err = this.validateNote(object, entryUri, actor); if (err) { - this.logger.error(err.message, { + this.logger.error(`Error creating note: ${renderInlineError(err)}`, { resolver: { history: resolver.getHistory() }, value, object, @@ -173,11 +175,11 @@ export class ApNoteService { this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); if (note.id == null) { - throw new UnrecoverableError(`Refusing to create note without id: ${entryUri}`); + throw new UnrecoverableError(`failed to create note ${entryUri}: missing ID`); } if (!checkHttps(note.id)) { - throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`); + throw new UnrecoverableError(`failed to create note ${entryUri}: unexpected schema`); } const url = this.apUtilityService.findBestObjectUrl(note); @@ -186,7 +188,7 @@ export class ApNoteService { // 投稿者をフェッチ if (note.attributedTo == null) { - throw new UnrecoverableError(`invalid note.attributedTo ${note.attributedTo} in ${entryUri}`); + throw new UnrecoverableError(`failed to create note: ${entryUri}: missing attributedTo`); } const uri = getOneApId(note.attributedTo); @@ -195,7 +197,7 @@ export class ApNoteService { // eslint-disable-next-line no-param-reassign actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined; if (actor && actor.isSuspended) { - throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${uri} has been suspended: ${entryUri}`); + throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create note ${entryUri}: actor ${uri} has been suspended`); } const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); @@ -222,7 +224,7 @@ export class ApNoteService { */ const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices }); if (hasProhibitedWords) { - throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${entryUri}`); + throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `failed to create note ${entryUri}: contains prohibited words`); } //#endregion @@ -231,7 +233,7 @@ export class ApNoteService { // 解決した投稿者が凍結されていたらスキップ if (actor.isSuspended) { - throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${entryUri}`); + throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create note ${entryUri}: actor ${actor.id} has been suspended`); } const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); @@ -268,15 +270,15 @@ export class ApNoteService { ? await this.resolveNote(note.inReplyTo, { resolver }) .then(x => { if (x == null) { - this.logger.warn('Specified inReplyTo, but not found'); - throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`); + this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`); + throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true); } return x; }) .catch(async err => { - this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); - throw err; + this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); + throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err); }) : null; @@ -284,6 +286,13 @@ export class ApNoteService { const quote = await this.getQuote(note, entryUri, resolver); const processErrors = quote === null ? ['quoteUnavailable'] : null; + if (reply && reply.userHost == null && reply.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note'); + } + if (quote && quote.userHost == null && quote.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note'); + } + // vote if (reply && reply.hasPoll) { const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); @@ -296,7 +305,7 @@ export class ApNoteService { await this.pollService.vote(actor, reply, index); // リモートフォロワーにUpdate配信 - this.pollService.deliverQuestionUpdate(reply.id); + this.pollService.deliverQuestionUpdate(reply); } return null; }; @@ -340,7 +349,7 @@ export class ApNoteService { 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: ${entryUri}`); + throw new IdentifiableError('39c328e1-e829-458b-bfc9-65dcd513d1f8', `failed to create note ${entryUri}: the note creation failed with duplication error even when there is no duplication. This is likely a bug.`); } return duplicate; } @@ -354,45 +363,39 @@ export class ApNoteService { const noteUri = getApId(value); // URIがこのサーバーを指しているならスキップ - if (noteUri.startsWith(this.config.url + '/')) throw new UnrecoverableError(`uri points local: ${noteUri}`); + if (this.utilityService.isUriLocal(noteUri)) { + throw new UnrecoverableError(`failed to update note ${noteUri}: uri is local`); + } //#region このサーバーに既に登録されているか const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri }); - if (updatedNote == null) throw new Error(`Note is not registered (no note): ${noteUri}`); + if (updatedNote == null) throw new UnrecoverableError(`failed to update note ${noteUri}: note does not exist`); const user = await this.usersRepository.findOneBy({ id: updatedNote.userId }) as MiRemoteUser | null; - if (user == null) throw new Error(`Note is not registered (no user): ${noteUri}`); + if (user == null) throw new UnrecoverableError(`failed to update note ${noteUri}: user does not exist`); - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(value); const entryUri = getApId(value); const err = this.validateNote(object, entryUri, actor, user); if (err) { - this.logger.error(err.message, { - resolver: { history: resolver.getHistory() }, - value, - object, - }); + this.logger.error(`Failed to update note ${noteUri}: ${renderInlineError(err)}`); throw err; } // `validateNote` checks that the actor and user are one and the same - // eslint-disable-next-line no-param-reassign actor ??= user; const note = object as IPost; - this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - if (note.id == null) { - throw new UnrecoverableError(`Refusing to update note without id: ${noteUri}`); + throw new UnrecoverableError(`failed to update note ${entryUri}: missing ID`); } if (!checkHttps(note.id)) { - throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`); + throw new UnrecoverableError(`failed to update note ${entryUri}: unexpected schema`); } const url = this.apUtilityService.findBestObjectUrl(note); @@ -400,7 +403,7 @@ export class ApNoteService { this.logger.info(`Creating the Note: ${note.id}`); if (actor.isSuspended) { - throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${actor.id} has been suspended: ${noteUri}`); + throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to update note ${entryUri}: actor ${actor.id} has been suspended`); } const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); @@ -425,9 +428,9 @@ export class ApNoteService { /** * 禁止ワードチェック */ - const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices }); + const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices }); if (hasProhibitedWords) { - throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${noteUri}`); + throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `failed to update note ${noteUri}: contains prohibited words`); } //#endregion @@ -465,15 +468,15 @@ export class ApNoteService { ? await this.resolveNote(note.inReplyTo, { resolver }) .then(x => { if (x == null) { - this.logger.warn('Specified inReplyTo, but not found'); - throw new Error(`could not fetch inReplyTo ${note.inReplyTo}: ${entryUri}`); + this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`); + throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true); } return x; }) .catch(async err => { - this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo}: ${entryUri}`); - throw err; + this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); + throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err); }) : null; @@ -481,6 +484,10 @@ export class ApNoteService { const quote = await this.getQuote(note, entryUri, resolver); const processErrors = quote === null ? ['quoteUnavailable'] : null; + if (quote && quote.userHost == null && quote.localOnly) { + throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note'); + } + // vote if (reply && reply.hasPoll) { const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); @@ -493,7 +500,7 @@ export class ApNoteService { await this.pollService.vote(actor, reply, index); // リモートフォロワーにUpdate配信 - this.pollService.deliverQuestionUpdate(reply.id); + this.pollService.deliverQuestionUpdate(reply); } return null; }; @@ -537,7 +544,7 @@ export class ApNoteService { 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: ${noteUri}`); + throw new IdentifiableError('39c328e1-e829-458b-bfc9-65dcd513d1f8', `failed to update note ${entryUri}: the note update failed with duplication error even when there is no duplication. This is likely a bug.`); } return duplicate; } @@ -550,29 +557,30 @@ export class ApNoteService { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise { + public async resolveNote(value: string | IObject, options: { sentFrom?: string, resolver?: Resolver } = {}): Promise { const uri = getApId(value); if (!this.utilityService.isFederationAllowedUri(uri)) { - throw new StatusError(`blocked host: ${uri}`, 451, 'blocked host'); + throw new IdentifiableError('04620a7e-044e-45ce-b72c-10e1bdc22e69', `failed to resolve note ${uri}: host is blocked`); + } + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await this.fetchNote(uri); + if (exist) return exist; + //#endregion + + // Bail if local URI doesn't exist + if (this.utilityService.isUriLocal(uri)) { + throw new IdentifiableError('cbac7358-23f2-4c70-833e-cffb4bf77913', `failed to resolve note ${uri}: URL is local and does not exist`); } const unlock = await this.appLockService.getApLock(uri); try { - //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.fetchNote(uri); - if (exist) return exist; - //#endregion - - if (this.utilityService.isUriLocal(uri)) { - throw new StatusError(`cannot resolve local note: ${uri}`, 400, 'cannot resolve local note'); - } - - // リモートサーバーからフェッチしてきて登録 - // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが - // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 - const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri; + // Optimization: we can avoid re-fetching the value *if and only if* it matches the host authority that it was sent from. + // Instances can create any object within their host authority, but anything outside of that MUST be untrusted. + const haveSameAuthority = options.sentFrom && this.apUtilityService.haveSameAuthority(options.sentFrom, uri); + const createFrom = haveSameAuthority ? value : uri; return await this.createNote(createFrom, undefined, options.resolver, true); } finally { unlock(); @@ -649,9 +657,29 @@ export class ApNoteService { */ private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise { const quoteUris = new Set(); - if (note._misskey_quote) quoteUris.add(note._misskey_quote); - if (note.quoteUrl) quoteUris.add(note.quoteUrl); - if (note.quoteUri) quoteUris.add(note.quoteUri); + if (note._misskey_quote && typeof(note._misskey_quote as unknown) === 'string') quoteUris.add(note._misskey_quote); + if (note.quoteUrl && typeof(note.quoteUrl as unknown) === 'string') quoteUris.add(note.quoteUrl); + if (note.quoteUri && typeof(note.quoteUri as unknown) === 'string') quoteUris.add(note.quoteUri); + + // https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md + if (note.quote && typeof(note.quote as unknown) === 'string') quoteUris.add(note.quote); + + // https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md + const tags = toArray(note.tag).filter(tag => typeof(tag) === 'object' && isLink(tag)); + for (const tag of tags) { + if (!tag.href || typeof (tag.href as unknown) !== 'string') continue; + + const mediaTypes = toArray(tag.mediaType); + if ( + !mediaTypes.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"') && + !mediaTypes.includes('application/activity+json') + ) continue; + + const rels = toArray(tag.rel); + if (!rels.includes('https://misskey-hub.net/ns#_misskey_quote')) continue; + + quoteUris.add(tag.href); + } // No quote, return undefined if (quoteUris.size < 1) return undefined; @@ -670,18 +698,13 @@ export class ApNoteService { const quote = await this.resolveNote(uri, { resolver }); if (quote == null) { - this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`); + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": fetch failed`); return false; } return quote; } catch (e) { - if (e instanceof Error) { - this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}":`, e); - } else { - this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${e}`); - } - + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${renderInlineError(e)}`); return isRetryableError(e); } }; diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index da29a3c527..29f7459219 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,11 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; -import { AbortError } from 'node-fetch'; import { UnrecoverableError } from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; @@ -40,6 +39,12 @@ import { RoleService } from '@/core/RoleService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { MemoryKVCache } from '@/misc/cache.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { verifyFieldLinks } from '@/misc/verify-field-link.js'; +import { isRetryableError } from '@/misc/is-retryable-error.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -50,6 +55,7 @@ import type { ApLoggerService } from '../ApLoggerService.js'; import type { ApImageService } from './ApImageService.js'; import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; const nameLength = 128; const summaryLength = 2048; @@ -57,7 +63,11 @@ const summaryLength = 2048; type Field = Record<'name' | 'value', string>; @Injectable() -export class ApPersonService implements OnModuleInit { +export class ApPersonService implements OnModuleInit, OnApplicationShutdown { + // Moved from ApDbResolverService + private readonly publicKeyByKeyIdCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h + private readonly publicKeyByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h + private utilityService: UtilityService; private userEntityService: UserEntityService; private driveFileEntityService: DriveFileEntityService; @@ -107,6 +117,8 @@ export class ApPersonService implements OnModuleInit { private roleService: RoleService, private readonly apUtilityService: ApUtilityService, + private readonly httpRequestService: HttpRequestService, + private readonly appLockService: AppLockService, ) { } @@ -132,6 +144,10 @@ export class ApPersonService implements OnModuleInit { this.logger = this.apLoggerService.logger; } + onApplicationShutdown(): void { + this.dispose(); + } + /** * Validate and convert to actor object * @param x Fetched object @@ -139,30 +155,33 @@ export class ApPersonService implements OnModuleInit { */ @bindThis private validateActor(x: IObject, uri: string): IActor { + this.apUtilityService.assertApUrl(uri); const expectHost = this.utilityService.punyHostPSLDomain(uri); if (!isActor(x)) { - throw new UnrecoverableError(`invalid Actor type '${x.type}' in ${uri}`); + throw new UnrecoverableError(`invalid Actor ${uri}: unknown type '${x.type}'`); } if (!(typeof x.id === 'string' && x.id.length > 0)) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong id type`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type`); } if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox type`); } + this.apUtilityService.assertApUrl(x.inbox); const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox); if (inboxHost !== expectHost) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox ${inboxHost}`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox host ${inboxHost}`); } const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined); if (sharedInboxObject != null) { const sharedInbox = getApId(sharedInboxObject); + this.apUtilityService.assertApUrl(sharedInbox); if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong shared inbox ${sharedInbox}`); } } @@ -171,8 +190,9 @@ export class ApPersonService implements OnModuleInit { if (xCollection != null) { const collectionUri = getApId(xCollection); if (typeof collectionUri === 'string' && collectionUri.length > 0) { + this.apUtilityService.assertApUrl(collectionUri); if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} host ${collectionUri}`); } } else if (collectionUri != null) { throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`); @@ -181,7 +201,7 @@ export class ApPersonService implements OnModuleInit { } if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong username`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong username`); } // These fields are only informational, and some AP software allows these @@ -189,7 +209,7 @@ export class ApPersonService implements OnModuleInit { // we can at least see these users and their activities. if (x.name) { if (!(typeof x.name === 'string' && x.name.length > 0)) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong name`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong name`); } x.name = truncate(x.name, nameLength); } else if (x.name === '') { @@ -198,24 +218,24 @@ export class ApPersonService implements OnModuleInit { } if (x.summary) { if (!(typeof x.summary === 'string' && x.summary.length > 0)) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong summary`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong summary`); } x.summary = truncate(x.summary, summaryLength); } const idHost = this.utilityService.punyHostPSLDomain(x.id); if (idHost !== expectHost) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong id ${x.id}`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong id ${x.id}`); } if (x.publicKey) { if (typeof x.publicKey.id !== 'string') { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id type`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id type`); } const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id); if (publicKeyIdHost !== expectHost) { - throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id ${x.publicKey.id}`); + throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id ${x.publicKey.id}`); } } @@ -253,8 +273,6 @@ export class ApPersonService implements OnModuleInit { } private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise>> { - if (user == null) throw new Error('failed to create user: user is null'); - const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => { // icon and image may be arrays // see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon @@ -307,19 +325,23 @@ export class ApPersonService implements OnModuleInit { */ @bindThis public async createPerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new UnrecoverableError(`uri is not string: ${uri}`); + if (typeof uri !== 'string') throw new UnrecoverableError(`failed to create user ${uri}: input is not string`); const host = this.utilityService.punyHost(uri); if (host === this.utilityService.toPuny(this.config.host)) { - throw new StatusError(`cannot resolve local user: ${uri}`, 400, 'cannot resolve local user'); + throw new UnrecoverableError(`failed to create user ${uri}: URI is local`); } - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + return await this._createPerson(uri, resolver); + } - const object = await resolver.resolve(uri); - if (object.id == null) throw new UnrecoverableError(`null object.id in ${uri}`); + private async _createPerson(value: string | IObject, resolver?: Resolver): Promise { + const uri = getApId(value); + const host = this.utilityService.punyHost(uri); + resolver ??= this.apResolverService.createResolver(); + + const object = await resolver.resolve(value); const person = this.validateActor(object, uri); this.logger.info(`Creating the Person: ${person.id}`); @@ -332,14 +354,16 @@ export class ApPersonService implements OnModuleInit { const [followingVisibility, followersVisibility] = await Promise.all( [ - this.isPublicCollection(person.following, resolver), - this.isPublicCollection(person.followers, resolver), + this.isPublicCollection(person.following, resolver, uri), + this.isPublicCollection(person.followers, resolver, uri), ].map((p): Promise<'public' | 'private'> => p .then(isPublic => isPublic ? 'public' : 'private') .catch(err => { - if (!(err instanceof StatusError) || err.isRetryable) { - this.logger.error('error occurred while fetching following/followers collection', { stack: err }); + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.error(`error occurred while fetching following/followers collection: ${renderInlineError(err)}`); } + return 'private'; }), ), @@ -348,28 +372,42 @@ export class ApPersonService implements OnModuleInit { const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); if (person.id == null) { - throw new UnrecoverableError(`Refusing to create person without id: ${uri}`); + throw new UnrecoverableError(`failed to create user ${uri}: missing ID`); } const url = this.apUtilityService.findBestObjectUrl(person); + const verifiedLinks = url ? await verifyFieldLinks(fields, url, this.httpRequestService) : []; + // Create user let user: MiRemoteUser | null = null; + let publicKey: MiUserPublickey | 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 }); + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.error(`error occurred while fetching user emojis: ${renderInlineError(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; }); + const outboxCollection = person.outbox + ? await resolver.resolveCollection(person.outbox, true, uri).catch(() => { return null; }) + : null; + const followersCollection = person.followers + ? await resolver.resolveCollection(person.followers, true, uri).catch(() => { return null; }) + : null; + const followingCollection = person.following + ? await resolver.resolveCollection(person.following, true, uri).catch(() => { return null; }) + : null; + + // Register the instance first, to avoid FK errors + await this.federatedInstanceService.fetchOrRegister(host); try { // Start transaction @@ -396,9 +434,9 @@ export class ApPersonService implements OnModuleInit { host, inbox: person.inbox, sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, - notesCount: outboxcollection?.totalItems ?? 0, - followersCount: followerscollection?.totalItems ?? 0, - followingCount: followingcollection?.totalItems ?? 0, + 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, @@ -410,6 +448,11 @@ export class ApPersonService implements OnModuleInit { makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null, makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null, emojis, + attributionDomains: Array.isArray(person.attributionDomains) + ? person.attributionDomains + .filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128) + .slice(0, 32) + : [], })) as MiRemoteUser; let _description: string | null = null; @@ -426,6 +469,7 @@ export class ApPersonService implements OnModuleInit { followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, url, fields, + verifiedLinks, followingVisibility, followersVisibility, birthday: bday?.[0] ?? null, @@ -435,7 +479,7 @@ export class ApPersonService implements OnModuleInit { })); if (person.publicKey) { - await transactionalEntityManager.save(new MiUserPublickey({ + publicKey = await transactionalEntityManager.save(new MiUserPublickey({ userId: user.id, keyId: person.publicKey.id, keyPem: person.publicKey.publicKeyPem.trim(), @@ -450,8 +494,9 @@ export class ApPersonService implements OnModuleInit { if (u == null) throw new UnrecoverableError(`already registered a user with conflicting data: ${uri}`); user = u as MiRemoteUser; + publicKey = await this.userPublickeysRepository.findOneBy({ userId: user.id }); } else { - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error creating Person:', e instanceof Error ? e : new Error(e as string)); throw e; } } @@ -461,6 +506,11 @@ export class ApPersonService implements OnModuleInit { // Register to the cache this.cacheService.uriPersonCache.set(user.uri, user); + // Register public key to the cache. + // Value may be null, which indicates that the user has no defined key. (optimization) + this.publicKeyByUserIdCache.set(user.id, publicKey); + if (publicKey) this.publicKeyByKeyIdCache.set(publicKey.keyId, publicKey); + // Register host if (this.meta.enableStatsForFederatedInstances) { this.federatedInstanceService.fetchOrRegister(host).then(i => { @@ -486,11 +536,19 @@ export class ApPersonService implements OnModuleInit { // 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 }); + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.error(`error occurred while fetching user avatar/banner: ${renderInlineError(err)}`); + } } //#endregion - await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)); + await this.updateFeatured(user.id, resolver).catch(err => { + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`); + } + }); return user; } @@ -507,7 +565,7 @@ export class ApPersonService implements OnModuleInit { */ @bindThis public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise { - if (typeof uri !== 'string') throw new UnrecoverableError('uri is not string'); + if (typeof uri !== 'string') throw new UnrecoverableError(`failed to update user ${uri}: input is not string`); // URIがこのサーバーを指しているならスキップ if (this.utilityService.isUriLocal(uri)) return; @@ -527,8 +585,11 @@ export class ApPersonService implements OnModuleInit { 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}`); + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(err => { + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.error(`error occurred while fetching user emojis: ${renderInlineError(err)}`); + } return []; }); @@ -540,16 +601,18 @@ export class ApPersonService implements OnModuleInit { const [followingVisibility, followersVisibility] = await Promise.all( [ - this.isPublicCollection(person.following, resolver), - this.isPublicCollection(person.followers, resolver), + this.isPublicCollection(person.following, resolver, exist.uri), + this.isPublicCollection(person.followers, resolver, exist.uri), ].map((p): Promise<'public' | 'private' | undefined> => p .then(isPublic => isPublic ? 'public' : 'private') .catch(err => { - if (!(err instanceof StatusError) || err.isRetryable) { - this.logger.error('error occurred while fetching following/followers collection', { stack: err }); - // Do not update the visibiility on transient errors. + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.error(`error occurred while fetching following/followers collection: ${renderInlineError(err)}`); + // Do not update the visibility on transient errors. return undefined; } + return 'private'; }), ), @@ -558,17 +621,19 @@ export class ApPersonService implements OnModuleInit { const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); if (person.id == null) { - throw new UnrecoverableError(`Refusing to update person without id: ${uri}`); + throw new UnrecoverableError(`failed to update user ${uri}: missing ID`); } const url = this.apUtilityService.findBestObjectUrl(person); + const verifiedLinks = url ? await verifyFieldLinks(fields, url, this.httpRequestService) : []; + const updates = { lastFetchedAt: new Date(), inbox: person.inbox, sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured, + featured: person.featured ? getApId(person.featured) : undefined, emojis: emojiNames, name: truncate(person.name, nameLength), tags, @@ -584,7 +649,20 @@ export class ApPersonService implements OnModuleInit { // We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic. hideOnlineStatus: person.hideOnlineStatus !== false, isExplorable: person.discoverable !== false, - ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))), + attributionDomains: Array.isArray(person.attributionDomains) + ? person.attributionDomains + .filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128) + .slice(0, 32) + : [], + ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(err => { + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.error(`error occurred while fetching user avatar/banner: ${renderInlineError(err)}`); + } + + // Can't return null or destructuring operator will break + return {}; + })), } as Partial & Pick; const moving = ((): boolean => { @@ -608,13 +686,32 @@ export class ApPersonService implements OnModuleInit { if (moving) updates.movedAt = new Date(); // Update user - await this.usersRepository.update(exist.id, updates); + if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) { + return `skip: user ${exist.id} is deleted`; + } if (person.publicKey) { - await this.userPublickeysRepository.update({ userId: exist.id }, { + const publicKey = new MiUserPublickey({ + userId: exist.id, keyId: person.publicKey.id, keyPem: person.publicKey.publicKeyPem, }); + + // Create or update key + await this.userPublickeysRepository.save(publicKey); + + this.publicKeyByKeyIdCache.set(person.publicKey.id, publicKey); + this.publicKeyByUserIdCache.set(exist.id, publicKey); + } else { + const existingPublicKey = await this.userPublickeysRepository.findOneBy({ userId: exist.id }); + if (existingPublicKey) { + // Delete key + await this.userPublickeysRepository.delete({ userId: exist.id }); + this.publicKeyByKeyIdCache.delete(existingPublicKey.keyId); + } + + // Null indicates that the user has no key. (optimization) + this.publicKeyByUserIdCache.set(exist.id, null); } let _description: string | null = null; @@ -628,6 +725,7 @@ export class ApPersonService implements OnModuleInit { await this.userProfilesRepository.update({ userId: exist.id }, { url, fields, + verifiedLinks, description: _description, followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, followingVisibility, @@ -643,12 +741,24 @@ export class ApPersonService implements OnModuleInit { this.hashtagService.updateUsertags(exist, tags); // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await this.followingsRepository.update( - { followerId: exist.id }, - { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null }, - ); + if (exist.inbox !== person.inbox || exist.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) { + await this.followingsRepository.update( + { followerId: exist.id }, + { + followerInbox: person.inbox, + followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, + }, + ); - await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); + await this.cacheService.refreshFollowRelationsFor(exist.id); + } + + await this.updateFeatured(exist.id, resolver).catch(err => { + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`); + } + }); const updated = { ...exist, ...updates }; @@ -683,16 +793,34 @@ export class ApPersonService implements OnModuleInit { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolvePerson(uri: string, resolver?: Resolver): Promise { + public async resolvePerson(value: string | IObject, resolver?: Resolver, sentFrom?: string): Promise { + const uri = getApId(value); + + if (!this.utilityService.isFederationAllowedUri(uri)) { + throw new IdentifiableError('590719b3-f51f-48a9-8e7d-6f559ad00e5d', `failed to resolve person ${uri}: host is blocked`); + } + //#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); + // Bail if local URI doesn't exist + if (this.utilityService.isUriLocal(uri)) { + throw new IdentifiableError('efb573fd-6b9e-4912-9348-a02f5603df4f', `failed to resolve person ${uri}: URL is local and does not exist`); + } + + const unlock = await this.appLockService.getApLock(uri); + + try { + // Optimization: we can avoid re-fetching the value *if and only if* it matches the host authority that it was sent from. + // Instances can create any object within their host authority, but anything outside of that MUST be untrusted. + const haveSameAuthority = sentFrom && this.apUtilityService.haveSameAuthority(sentFrom, uri); + const createFrom = haveSameAuthority ? value : uri; + return await this._createPerson(createFrom, resolver); + } finally { + unlock(); + } } @bindThis @@ -714,7 +842,7 @@ export class ApPersonService implements OnModuleInit { @bindThis public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise { - const user = await this.usersRepository.findOneByOrFail({ id: userId }); + const user = await this.usersRepository.findOneByOrFail({ id: userId, isDeleted: false }); if (!this.userEntityService.isRemoteUser(user)) return; if (!user.featured) return; @@ -723,16 +851,17 @@ export class ApPersonService implements OnModuleInit { const _resolver = resolver ?? this.apResolverService.createResolver(); // Resolve to (Ordered)Collection Object - const collection = await _resolver.resolveCollection(user.featured).catch(err => { - if (err instanceof AbortError || err instanceof StatusError) { - this.logger.warn(`Failed to update featured notes: ${err.name}: ${err.message}`); - } else { - this.logger.error('Failed to update featured notes:', err); + const collection = user.featured ? await _resolver.resolveCollection(user.featured, true, user.uri).catch(err => { + // Permanent error implies hidden or inaccessible, which is a normal thing. + if (isRetryableError(err)) { + this.logger.warn(`Failed to update featured notes: ${renderInlineError(err)}`); } - }); + + return null; + }) : null; if (!collection) return; - if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`featured ${user.featured} is not Collection or OrderedCollection in ${user.uri}`); + if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`failed to update user ${user.uri}: featured ${user.featured} is not Collection or OrderedCollection`); // Resolve to Object(may be Note) arrays const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; @@ -746,7 +875,7 @@ export class ApPersonService implements OnModuleInit { .slice(0, maxPinned) .map(item => limit(() => this.apNoteService.resolveNote(item, { resolver: _resolver, - sentFrom: new URL(user.uri), + sentFrom: user.uri, })))); await this.db.transaction(async transactionalEntityManager => { @@ -815,14 +944,50 @@ export class ApPersonService implements OnModuleInit { } @bindThis - private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise { + private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver, sentFrom: string): Promise { if (collection) { - const resolved = await resolver.resolveCollection(collection); - if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) { - return true; + const resolved = await resolver.resolveCollection(collection, true, sentFrom).catch(() => null); + if (resolved) { + if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) { + return true; + } } } return false; } + + @bindThis + public async findPublicKeyByUserId(userId: string): Promise { + const publicKey = this.publicKeyByUserIdCache.get(userId) ?? await this.userPublickeysRepository.findOneBy({ userId }); + + // This can technically keep a key cached "forever" if it's used enough, but that's ok. + // We can never have stale data because the publicKey caches are coherent. (cache updates whenever data changes) + if (publicKey) { + this.publicKeyByUserIdCache.set(publicKey.userId, publicKey); + this.publicKeyByKeyIdCache.set(publicKey.keyId, publicKey); + } + + return publicKey; + } + + @bindThis + public async findPublicKeyByKeyId(keyId: string): Promise { + const publicKey = this.publicKeyByKeyIdCache.get(keyId) ?? await this.userPublickeysRepository.findOneBy({ keyId }); + + // This can technically keep a key cached "forever" if it's used enough, but that's ok. + // We can never have stale data because the publicKey caches are coherent. (cache updates whenever data changes) + if (publicKey) { + this.publicKeyByUserIdCache.set(publicKey.userId, publicKey); + this.publicKeyByKeyIdCache.set(publicKey.keyId, publicKey); + } + + return publicKey; + } + + @bindThis + public dispose(): void { + this.publicKeyByUserIdCache.dispose(); + this.publicKeyByKeyIdCache.dispose(); + } } diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index 335ca189ec..80900be2dc 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -93,7 +93,6 @@ export class ApQuestionService { // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); const question = await resolver.resolve(value); - this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); if (!isQuestion(question)) throw new UnrecoverableError(`object ${getApType(question)} is not a Question: ${uri}`); diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index d8e7b3c9c3..554420d670 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -26,7 +26,7 @@ export interface IObject { attributedTo?: ApObject; attachment?: any[]; inReplyTo?: any; - replies?: ICollection; + replies?: ICollection | IOrderedCollection | string; content?: string | null; startTime?: Date; endTime?: Date; @@ -35,10 +35,37 @@ export interface IObject { mediaType?: string; url?: ApObject | string; href?: string; + rel?: string | string[]; tag?: IObject | IObject[]; sensitive?: boolean; } +export interface IObjectWithId extends IObject { + id: string; +} + +export function isObjectWithId(object: IObject): object is IObjectWithId { + return typeof(object.id) === 'string'; +} + +export interface IAnonymousObject extends IObject { + id: undefined; +} + +export function isAnonymousObject(object: IObject): object is IAnonymousObject { + return object.id === undefined; +} + +export interface ILink extends IObject { + '@context'?: string | string[] | Obj | Obj[]; + type: 'Link' | 'Mention'; + href: string; +} + +export const isLink = (object: IObject): object is ILink => + (getApType(object) === 'Link' || getApType(object) === 'Link') && + typeof object.href === 'string'; + /** * Get array of ActivityStreams Objects id */ @@ -59,24 +86,34 @@ export function getOneApId(value: ApObject): string { /** * Get ActivityStreams Object id */ -export function getApId(value: string | IObject | [string | IObject]): string { - // eslint-disable-next-line no-param-reassign - value = fromTuple(value); +export function getApId(value: string | IObject | [string | IObject], sourceForLogs?: string): string { + const id = getNullableApId(value); - if (typeof value === 'string') return value; - if (typeof value.id === 'string') return value.id; - throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`); + if (id == null) { + const message = sourceForLogs + ? `invalid AP object ${value} (sent from ${sourceForLogs}): missing id` + : `invalid AP object ${value}: missing id`; + throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', message); + } + + return id; } /** * Get ActivityStreams Object id, or null if not present */ -export function getNullableApId(value: string | IObject | [string | IObject]): string | null { - // eslint-disable-next-line no-param-reassign - value = fromTuple(value); +export function getNullableApId(source: string | IObject | [string | IObject]): string | null { + const value: unknown = fromTuple(source); + + if (value != null) { + if (typeof value === 'string') { + return value; + } + if (typeof (value) === 'object' && 'id' in value && typeof (value.id) === 'string') { + return value.id; + } + } - if (typeof value === 'string') return value; - if (typeof value.id === 'string') return value.id; return null; } @@ -108,7 +145,7 @@ export interface IActivity extends IObject { actor: IObject | string; // ActivityPub spec allows for arrays: https://www.w3.org/TR/activitystreams-vocabulary/#properties // Misskey can only handle one value, so we use a tuple for that case. - object: IObject | string | [IObject | string] ; + object: IObject | string | [IObject | string]; target?: IObject | string; /** LD-Signature */ signature?: { @@ -121,20 +158,46 @@ export interface IActivity extends IObject { }; } -export interface ICollection extends IObject { - type: 'Collection'; - totalItems: number; +export interface CollectionBase extends IObject { + totalItems?: number; first?: IObject | string; + last?: IObject | string; + current?: IObject | string; + partOf?: IObject | string; + next?: IObject | string; + prev?: IObject | string; items?: ApObject; -} - -export interface IOrderedCollection extends IObject { - type: 'OrderedCollection'; - totalItems: number; - first?: IObject | string; orderedItems?: ApObject; } +export interface ICollection extends CollectionBase { + type: 'Collection'; + totalItems: number; + items?: ApObject; + orderedItems?: undefined; +} + +export interface IOrderedCollection extends CollectionBase { + type: 'OrderedCollection'; + totalItems: number; + items?: undefined; + orderedItems?: ApObject; +} + +export interface ICollectionPage extends CollectionBase { + type: 'CollectionPage'; + items?: ApObject; + orderedItems?: undefined; +} + +export interface IOrderedCollectionPage extends CollectionBase { + type: 'OrderedCollectionPage'; + items?: undefined; + orderedItems?: ApObject; +} + +export type AnyCollection = ICollection | IOrderedCollection | ICollectionPage | IOrderedCollectionPage; + export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; export const isPost = (object: IObject): object is IPost => { @@ -152,6 +215,7 @@ export interface IPost extends IObject { _misskey_content?: string; quoteUrl?: string; quoteUri?: string; + quote?: string; updated?: string; } @@ -223,6 +287,7 @@ export interface IActor extends IObject { enableRss?: boolean; listenbrainz?: string; backgroundUrl?: string; + attributionDomains?: string[]; } export const isCollection = (object: IObject): object is ICollection => @@ -231,8 +296,14 @@ export const isCollection = (object: IObject): object is ICollection => export const isOrderedCollection = (object: IObject): object is IOrderedCollection => getApType(object) === 'OrderedCollection'; -export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => - isCollection(object) || isOrderedCollection(object); +export const isCollectionPage = (object: IObject): object is ICollectionPage => + getApType(object) === 'CollectionPage'; + +export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage => + getApType(object) === 'OrderedCollectionPage'; + +export const isCollectionOrOrderedCollection = (object: IObject): object is AnyCollection => + isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object); export interface IApPropertyValue extends IObject { type: 'PropertyValue'; @@ -247,9 +318,8 @@ export const isPropertyValue = (object: IObject): object is IApPropertyValue => 'value' in object && typeof object.value === 'string'; -export interface IApMention extends IObject { +export interface IApMention extends ILink { type: 'Mention'; - href: string; name: string; } @@ -369,6 +439,13 @@ export interface IMove extends IActivity { target: IObject | string; } +export const validActivityTypes = ['Announce', 'Create', 'Update', 'Delete', 'Undo', 'Follow', 'Accept', 'Reject', 'Add', 'Remove', 'Like', 'Dislike', 'EmojiReaction', 'EmojiReact', 'Flag', 'Block', 'Move']; + +export const isActivity = (object: IObject): object is IActivity => { + const type = getApType(object); + return type != null && validActivityTypes.includes(type); +}; + export const isApObject = (object: string | IObject): object is IObject => typeof(object) === 'object'; export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index c9b43cc66d..4bbb5437cc 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -44,10 +44,7 @@ export default class FederationChart extends Chart { // eslint-di } protected async tickMinor(): Promise>> { - const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') - .select('instance.host') - .where('instance.suspensionState != \'none\''); - + // TODO optimization: replace these with exists() const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') .select('f.followerHost') .where('f.followerHost IS NOT NULL'); @@ -64,22 +61,25 @@ export default class FederationChart extends Chart { // eslint-di this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .innerJoin('following.followeeInstance', 'followeeInstance') + .andWhere('followeeInstance.suspensionState = \'none\'') + .andWhere('followeeInstance.isBlocked = false') .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followerHost)') .where('following.followerHost IS NOT NULL') - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .innerJoin('following.followerInstance', 'followerInstance') + .andWhere('followerInstance.isBlocked = false') + .andWhere('followerInstance.suspensionState = \'none\'') .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .innerJoin('following.followeeInstance', 'followeeInstance') + .andWhere('followeeInstance.isBlocked = false') + .andWhere('followeeInstance.suspensionState = \'none\'') .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) .setParameters(pubsubSubQuery.getParameters()) .getRawOne() @@ -87,7 +87,7 @@ export default class FederationChart extends Chart { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere('instance.isBlocked = false') .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() @@ -95,7 +95,7 @@ export default class FederationChart extends Chart { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) - .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere('instance.isBlocked = false') .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts index 588ac638de..8d75a30e9a 100644 --- a/packages/backend/src/core/chart/charts/per-user-following.ts +++ b/packages/backend/src/core/chart/charts/per-user-following.ts @@ -15,6 +15,7 @@ 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'; +import { CacheService } from '@/core/CacheService.js'; /** * ユーザーごとのフォローに関するチャート @@ -31,23 +32,25 @@ export default class PerUserFollowingChart extends Chart { // esl private appLockService: AppLockService, private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, + private readonly cacheService: CacheService, ) { super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { const [ - localFollowingsCount, - localFollowersCount, - remoteFollowingsCount, - remoteFollowersCount, + followees, + followers, ] = 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()) }), + this.cacheService.userFollowingsCache.fetch(group).then(fs => Array.from(fs.values())), + this.cacheService.userFollowersCache.fetch(group).then(fs => Array.from(fs.values())), ]); + const localFollowingsCount = followees.reduce((sum, f) => sum + (f.followeeHost == null ? 1 : 0), 0); + const localFollowersCount = followers.reduce((sum, f) => sum + (f.followerHost == null ? 1 : 0), 0); + const remoteFollowingsCount = followees.reduce((sum, f) => sum + (f.followeeHost == null ? 0 : 1), 0); + const remoteFollowersCount = followers.reduce((sum, f) => sum + (f.followerHost == null ? 0 : 1), 0); + return { 'local.followings.total': localFollowingsCount, 'local.followers.total': localFollowersCount, diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index 70ead890ab..c1d877aa12 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -5,13 +5,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { AbuseUserReportsRepository } from '@/models/_.js'; +import type { AbuseUserReportsRepository, InstancesRepository, MiInstance, MiUser } 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 type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; +import { InstanceEntityService } from './InstanceEntityService.js'; @Injectable() export class AbuseUserReportEntityService { @@ -19,6 +20,10 @@ export class AbuseUserReportEntityService { @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private readonly instanceEntityService: InstanceEntityService, private userEntityService: UserEntityService, private idService: IdService, ) { @@ -30,11 +35,14 @@ export class AbuseUserReportEntityService { hint?: { packedReporter?: Packed<'UserDetailedNotMe'>, packedTargetUser?: Packed<'UserDetailedNotMe'>, + packedTargetInstance?: Packed<'FederationInstance'>, packedAssignee?: Packed<'UserDetailedNotMe'>, }, + me?: MiUser | null, ) { const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); + // noinspection ES6MissingAwait return await awaitAll({ id: report.id, createdAt: this.idService.parse(report.id).date.toISOString(), @@ -43,13 +51,22 @@ export class AbuseUserReportEntityService { reporterId: report.reporterId, targetUserId: report.targetUserId, assigneeId: report.assigneeId, - reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, null, { + reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, me, { schema: 'UserDetailedNotMe', }), - targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { + targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, me, { schema: 'UserDetailedNotMe', }), - assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { + // return hint, or pack by relation, or fetch and pack by id, or null + targetInstance: hint?.packedTargetInstance ?? ( + report.targetUserInstance + ? this.instanceEntityService.pack(report.targetUserInstance, me) + : report.targetUserHost + ? this.instancesRepository.findOneBy({ host: report.targetUserHost }).then(instance => instance + ? this.instanceEntityService.pack(instance, me) + : null) + : null), + assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, me, { schema: 'UserDetailedNotMe', }) : null, forwarded: report.forwarded, @@ -61,21 +78,28 @@ export class AbuseUserReportEntityService { @bindThis public async packMany( reports: MiAbuseUserReport[], + me?: MiUser | null, ) { const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId); const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId); const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null); const _userMap = await this.userEntityService.packMany( [..._reporters, ..._targetUsers, ..._assignees], - null, + me, { schema: 'UserDetailedNotMe' }, ).then(users => new Map(users.map(u => [u.id, u]))); + const _targetInstances = reports + .map(({ targetUserInstance, targetUserHost }) => targetUserInstance ?? targetUserHost) + .filter((i): i is MiInstance | string => i != null); + const _instanceMap = await this.instanceEntityService.packMany(await this.instanceEntityService.fetchInstancesByHost(_targetInstances), me) + .then(instances => new Map(instances.map(i => [i.host, i]))); return Promise.all( reports.map(report => { const packedReporter = _userMap.get(report.reporterId); const packedTargetUser = _userMap.get(report.targetUserId); + const packedTargetInstance = report.targetUserHost ? _instanceMap.get(report.targetUserHost) : undefined; const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined; - return this.pack(report, { packedReporter, packedTargetUser, packedAssignee }); + return this.pack(report, { packedReporter, packedTargetUser, packedAssignee, packedTargetInstance }, me); }), ); } diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index e770028af3..1f8c8ae3e8 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -41,6 +41,7 @@ export class AntennaEntityService { excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, + excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel, isActive: antenna.isActive, hasUnreadNote: false, // TODO notify: false, // 後方互換性のため diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts new file mode 100644 index 0000000000..da112d5444 --- /dev/null +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -0,0 +1,376 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } 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 { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { In } from 'typeorm'; + +@Injectable() +export class ChatEntityService { + constructor( + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + + @Inject(DI.chatRoomMembershipsRepository) + private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private idService: IdService, + ) { + } + + @bindThis + public async packMessageDetailed( + src: MiChatMessage['id'] | MiChatMessage, + me?: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedFiles?: Map | null>; + packedUsers?: Map>; + packedRooms?: Map | null>; + }; + }, + ): Promise> { + const packedUsers = options?._hint_?.packedUsers; + const packedFiles = options?._hint_?.packedFiles; + const packedRooms = options?._hint_?.packedRooms; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me), + toUserId: message.toUserId, + toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined, + toRoomId: message.toRoomId, + toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesDetailed( + messages: MiChatMessage[], + me: { id: MiUser['id'] }, + ) { + if (messages.length === 0) return []; + + const excludeMe = (x: MiUser | string) => { + if (typeof x === 'string') { + return x !== me.id; + } else { + return x.id !== me.id; + } + }; + + const users = [ + ...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe), + ...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe), + ]; + + const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0])); + + for (const reactedUserId of reactedUserIds) { + if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) { + users.push(reactedUserId); + } + } + + const [packedUsers, packedFiles, packedRooms] = await Promise.all([ + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me) + .then(rooms => new Map(rooms.map(r => [r.id, r]))), + ]); + + return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } }))); + } + + @bindThis + public async packMessageLiteFor1on1( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map | null>; + }; + }, + ): Promise> { + const packedFiles = options?._hint_?.packedFiles; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + toUserId: message.toUserId!, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesLiteFor1on1( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const [packedFiles] = await Promise.all([ + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + ]); + + return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } }))); + } + + @bindThis + public async packMessageLiteForRoom( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map | null>; + packedUsers: Map>; + }; + }, + ): Promise> { + const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId), + toRoomId: message.toRoomId!, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesLiteForRoom( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const users = messages.map(x => x.fromUser ?? x.fromUserId); + const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0])); + + for (const reactedUserId of reactedUserIds) { + if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) { + users.push(reactedUserId); + } + } + + const [packedUsers, packedFiles] = await Promise.all([ + this.userEntityService.packMany(users) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + ]); + + return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } }))); + } + + @bindThis + public async packRoom( + src: MiChatRoom['id'] | MiChatRoom, + me?: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedOwners: Map>; + memberships?: Map; + }; + }, + ): Promise> { + const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src }); + + const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null; + + return { + id: room.id, + createdAt: this.idService.parse(room.id).date.toISOString(), + name: room.name, + description: room.description, + ownerId: room.ownerId, + owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me), + isMuted: membership != null ? membership.isMuted : false, + }; + } + + @bindThis + public async packRooms( + rooms: (MiChatRoom | MiChatRoom['id'])[], + me: { id: MiUser['id'] }, + ) { + if (rooms.length === 0) return []; + + const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string'); + if (_rooms.length !== rooms.length) { + _rooms.push( + ...await this.chatRoomsRepository.find({ + where: { + id: In(rooms.filter((room): room is string => typeof room === 'string')), + }, + relations: ['owner'], + }), + ); + } + + const owners = _rooms.map(x => x.owner ?? x.ownerId); + + const [packedOwners, memberships] = await Promise.all([ + this.userEntityService.packMany(owners, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.chatRoomMembershipsRepository.find({ + where: { + roomId: In(_rooms.map(x => x.id)), + userId: me.id, + }, + }).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))), + ]); + + return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } }))); + } + + @bindThis + public async packRoomInvitation( + src: MiChatRoomInvitation['id'] | MiChatRoomInvitation, + me: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedRooms: Map>; + packedUsers: Map>; + }; + }, + ): Promise> { + const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + createdAt: this.idService.parse(invitation.id).date.toISOString(), + roomId: invitation.roomId, + room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me), + userId: invitation.userId, + user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me), + }; + } + + @bindThis + public async packRoomInvitations( + invitations: MiChatRoomInvitation[], + me: { id: MiUser['id'] }, + ) { + if (invitations.length === 0) return []; + + return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me))); + } + + @bindThis + public async packRoomMembership( + src: MiChatRoomMembership['id'] | MiChatRoomMembership, + me: { id: MiUser['id'] }, + options?: { + populateUser?: boolean; + populateRoom?: boolean; + _hint_?: { + packedRooms: Map>; + packedUsers: Map>; + }; + }, + ): Promise> { + const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src }); + + return { + id: membership.id, + createdAt: this.idService.parse(membership.id).date.toISOString(), + userId: membership.userId, + user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined, + roomId: membership.roomId, + room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined, + }; + } + + @bindThis + public async packRoomMemberships( + memberships: MiChatRoomMembership[], + me: { id: MiUser['id'] }, + options: { + populateUser?: boolean; + populateRoom?: boolean; + } = {}, + ) { + if (memberships.length === 0) return []; + + const users = memberships.map(x => x.user ?? x.userId); + const rooms = memberships.map(x => x.room ?? x.roomId); + + const [packedUsers, packedRooms] = await Promise.all([ + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.packRooms(rooms, me) + .then(rooms => new Map(rooms.map(r => [r.id, r]))), + ]); + + return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } }))); + } +} diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index fcc9bed3bd..4ca4ff650b 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -4,6 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import type { Packed } from '@/misc/json-schema.js'; import type { MiInstance } from '@/models/Instance.js'; import { bindThis } from '@/decorators.js'; @@ -11,7 +12,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { RoleService } from '@/core/RoleService.js'; import { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; -import { MiMeta } from '@/models/_.js'; +import type { InstancesRepository, MiMeta } from '@/models/_.js'; @Injectable() export class InstanceEntityService { @@ -19,6 +20,9 @@ export class InstanceEntityService { @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.instancesRepository) + private readonly instancesRepository: InstancesRepository, + private roleService: RoleService, private utilityService: UtilityService, @@ -43,7 +47,7 @@ export class InstanceEntityService { isNotResponding: instance.isNotResponding, isSuspended: instance.suspensionState !== 'none', suspensionState: instance.suspensionState, - isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host), + isBlocked: instance.isBlocked, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, openRegistrations: instance.openRegistrations, @@ -51,8 +55,8 @@ export class InstanceEntityService { description: instance.description, maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, - isSilenced: this.utilityService.isSilencedHost(this.meta.silencedHosts, instance.host), - isMediaSilenced: this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, instance.host), + isSilenced: instance.isSilenced, + isMediaSilenced: instance.isMediaSilenced, iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, @@ -62,6 +66,7 @@ export class InstanceEntityService { rejectReports: instance.rejectReports, rejectQuotes: instance.rejectQuotes, moderationNote: iAmModerator ? instance.moderationNote : null, + isBubbled: this.utilityService.isBubbledHost(instance.host), }; } @@ -72,5 +77,28 @@ export class InstanceEntityService { ) { return Promise.all(instances.map(x => this.pack(x, me))); } + + @bindThis + public async fetchInstancesByHost(instances: (MiInstance | MiInstance['host'])[]): Promise { + const result: MiInstance[] = []; + + const toFetch: string[] = []; + for (const instance of instances) { + if (typeof(instance) === 'string') { + toFetch.push(instance); + } else { + result.push(instance); + } + } + + if (toFetch.length > 0) { + const fetched = await this.instancesRepository.findBy({ + host: In(toFetch), + }); + result.push(...fetched); + } + + return result; + } } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 84d591ce7a..294187feba 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -10,8 +10,7 @@ import type { Packed } from '@/misc/json-schema.js'; import type { MiMeta } from '@/models/Meta.js'; import type { AdsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; @@ -28,8 +27,7 @@ export class MetaEntityService { @Inject(DI.adsRepository) private adsRepository: AdsRepository, - private userEntityService: UserEntityService, - private instanceActorService: InstanceActorService, + private systemAccountService: SystemAccountService, ) { } @bindThis @@ -135,12 +133,13 @@ export class MetaEntityService { enableEmail: instance.enableEmail, enableServiceWorker: instance.enableServiceWorker, - translatorAvailable: instance.deeplAuthKey != null || instance.deeplFreeMode && instance.deeplFreeInstance != null, + translatorAvailable: instance.deeplAuthKey != null || instance.libreTranslateURL != null || instance.deeplFreeMode && instance.deeplFreeInstance != null, serverRules: instance.serverRules, policies: { ...DEFAULT_POLICIES, ...instance.policies }, + sentryForFrontend: this.config.sentryForFrontend ?? null, mediaProxy: this.config.mediaProxy, enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', @@ -161,14 +160,14 @@ export class MetaEntityService { const packed = await this.pack(instance); - const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null; + const proxyAccount = await this.systemAccountService.fetch('proxy'); const packDetailed: Packed<'MetaDetailed'> = { ...packed, cacheRemoteFiles: instance.cacheRemoteFiles, cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, - requireSetup: !await this.instanceActorService.realLocalUsersPresent(), - proxyAccountName: proxyAccount ? proxyAccount.username : null, + requireSetup: this.meta.rootUserId == null, + proxyAccountName: proxyAccount.username, features: { localTimeline: instance.policies.ltlAvailable, globalTimeline: instance.policies.gtlAvailable, @@ -181,6 +180,7 @@ export class MetaEntityService { serviceWorker: instance.enableServiceWorker, miauth: true, }, + allowUnsignedFetch: instance.allowUnsignedFetch, }; return packDetailed; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 537677ed34..4248fde77f 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,12 +11,13 @@ 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 { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { isPackedPureRenote } from '@/misc/is-renote.js'; +import type { Config } from '@/config.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CacheService } from '../CacheService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; @@ -25,13 +26,13 @@ import type { UserEntityService } from './UserEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; // is-renote.tsとよしなにリンク -function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } { +function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id'] } { return ( - note.renote != null && - note.reply == null && + note.renoteId != null && + note.replyId == null && note.text == null && note.cw == null && - (note.fileIds == null || note.fileIds.length === 0) && + note.fileIds.length === 0 && !note.hasPoll ); } @@ -92,6 +93,9 @@ export class NoteEntityService implements OnModuleInit { @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, + @Inject(DI.config) + private readonly config: Config, + //private userEntityService: UserEntityService, //private driveFileEntityService: DriveFileEntityService, //private customEmojiService: CustomEmojiService, @@ -128,7 +132,10 @@ export class NoteEntityService implements OnModuleInit { } @bindThis - public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise { + public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: { + myFollowing?: ReadonlyMap, + myBlockers?: ReadonlySet, + }): Promise { if (meId === packedNote.userId) return; // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) @@ -184,14 +191,9 @@ export class NoteEntityService implements OnModuleInit { } else if (packedNote.renote && (meId === packedNote.renote.userId)) { hide = false; } else { - // フォロワーかどうか - // TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: packedNote.userId, - followerId: meId, - }, - }); + const isFollowing = hint?.myFollowing + ? hint.myFollowing.has(packedNote.userId) + : (await this.cacheService.userFollowingsCache.fetch(meId)).has(packedNote.userId); hide = !isFollowing; } @@ -207,7 +209,8 @@ export class NoteEntityService implements OnModuleInit { } if (!hide && meId && packedNote.userId !== meId) { - const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(packedNote.userId); + const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId); + const isBlocked = blockers.has(packedNote.userId); if (isBlocked) hide = true; } @@ -231,8 +234,11 @@ export class NoteEntityService implements OnModuleInit { } @bindThis - private async populatePoll(note: MiNote, meId: MiUser['id'] | null) { - const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); + private async populatePoll(note: MiNote, meId: MiUser['id'] | null, hint?: { + poll?: MiPoll, + myVotes?: MiPollVote[], + }) { + const poll = hint?.poll ?? await this.pollsRepository.findOneByOrFail({ noteId: note.id }); const choices = poll.choices.map(c => ({ text: c, votes: poll.votes[poll.choices.indexOf(c)], @@ -241,7 +247,7 @@ export class NoteEntityService implements OnModuleInit { if (meId) { if (poll.multiple) { - const votes = await this.pollVotesRepository.findBy({ + const votes = hint?.myVotes ?? await this.pollVotesRepository.findBy({ userId: meId, noteId: note.id, }); @@ -251,7 +257,7 @@ export class NoteEntityService implements OnModuleInit { choices[myChoice].isVoted = true; } } else { - const vote = await this.pollVotesRepository.findOneBy({ + const vote = hint?.myVotes ? hint.myVotes[0] : await this.pollVotesRepository.findOneBy({ userId: meId, noteId: note.id, }); @@ -313,7 +319,12 @@ export class NoteEntityService implements OnModuleInit { } @bindThis - public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null): Promise { + public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null, hint?: { + myFollowing?: ReadonlySet, + myBlocking?: ReadonlySet, + myBlockers?: ReadonlySet, + me?: Pick | null, + }): Promise { // This code must always be synchronized with the checks in generateVisibilityQuery. // visibility が specified かつ自分が指定されていなかったら非表示 if (note.visibility === 'specified') { @@ -341,16 +352,16 @@ export class NoteEntityService implements OnModuleInit { return true; } else { // フォロワーかどうか - const [blocked, following, user] = await Promise.all([ - this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)), - this.followingsRepository.count({ - where: { - followeeId: note.userId, - followerId: meId, - }, - take: 1, - }), - this.usersRepository.findOneByOrFail({ id: meId }), + const [blocked, following, userHost] = await Promise.all([ + hint?.myBlocking + ? hint.myBlocking.has(note.userId) + : this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)), + hint?.myFollowing + ? hint.myFollowing.has(note.userId) + : this.cacheService.userFollowingsCache.fetch(meId).then(ids => ids.has(note.userId)), + hint?.me !== undefined + ? (hint.me?.host ?? null) + : this.cacheService.findUserById(meId).then(me => me.host), ]); if (blocked) return false; @@ -362,12 +373,13 @@ export class NoteEntityService implements OnModuleInit { 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 following || (note.userHost != null && userHost != null); } } if (meId != null) { - const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(note.userId); + const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId); + const isBlocked = blockers.has(note.userId); if (isBlocked) return false; } @@ -402,7 +414,14 @@ export class NoteEntityService implements OnModuleInit { bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; myReactions: Map; packedFiles: Map | null>; - packedUsers: Map> + packedUsers: Map>; + mentionHandles: Record; + userFollowings: Map>>; + userBlockers: Map>; + polls: Map; + pollVotes: Map>; + channels: Map; + notes: Map; }; }, ): Promise> { @@ -432,9 +451,7 @@ export class NoteEntityService implements OnModuleInit { } const channel = note.channelId - ? note.channel - ? note.channel - : await this.channelsRepository.findOneBy({ id: note.channelId }) + ? (opts._hint_?.channels.get(note.channelId) ?? note.channel ?? await this.channelsRepository.findOneBy({ id: note.channelId })) : null; const reactionEmojiNames = Object.keys(reactions) @@ -476,10 +493,15 @@ export class NoteEntityService implements OnModuleInit { allowRenoteToExternal: channel.allowRenoteToExternal, userId: channel.userId, } : undefined, - mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, + mentions: note.mentions.length > 0 ? note.mentions : undefined, + mentionHandles: note.mentions.length > 0 ? this.getUserHandles(note.mentions, options?._hint_?.mentionHandles) : undefined, uri: note.uri ?? undefined, url: note.url ?? undefined, - poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, + poll: note.hasPoll ? this.populatePoll(note, meId, { + poll: opts._hint_?.polls.get(note.id), + myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId), + }) : undefined, + ...(meId && Object.keys(reactions).length > 0 ? { myReaction: this.populateMyReaction({ id: note.id, @@ -492,14 +514,14 @@ export class NoteEntityService implements OnModuleInit { clippedCount: note.clippedCount, processErrors: note.processErrors, - reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { + reply: note.replyId ? this.pack(note.reply ?? opts._hint_?.notes.get(note.replyId) ?? 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, { + renote: note.renoteId ? this.pack(note.renote ?? opts._hint_?.notes.get(note.renoteId) ?? note.renoteId, me, { detail: true, skipHide: opts.skipHide, withReactionAndUserPairCache: opts.withReactionAndUserPairCache, @@ -511,7 +533,10 @@ export class NoteEntityService implements OnModuleInit { this.treatVisibility(packed); if (!opts.skipHide) { - await this.hideNote(packed, meId); + await this.hideNote(packed, meId, meId == null ? undefined : { + myFollowing: opts._hint_?.userFollowings.get(meId), + myBlockers: opts._hint_?.userBlockers.get(meId), + }); } return packed; @@ -528,70 +553,139 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; - const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; - - const meId = me ? me.id : null; - const myReactionsMap = new Map(); - if (meId) { - const idsNeedFetchMyReaction = new Set(); - - const targetNotes: MiNote[] = []; - for (const note of notes) { - if (isPureRenote(note)) { - // we may need to fetch 'my reaction' for renote target. - targetNotes.push(note.renote); + const targetNotesMap = new Map(); + const targetNotesToFetch : string[] = []; + for (const note of notes) { + if (isPureRenote(note)) { + // we may need to fetch 'my reaction' for renote target. + if (note.renote) { + targetNotesMap.set(note.renote.id, note.renote); if (note.renote.reply) { // idem if the renote is also a reply. - targetNotes.push(note.renote.reply); + targetNotesMap.set(note.renote.reply.id, note.renote.reply); } - } else { - if (note.reply) { - // idem for OP of a regular reply. - targetNotes.push(note.reply); - } - - targetNotes.push(note); + } else if (options?.detail) { + targetNotesToFetch.push(note.renoteId); } - } - - for (const note of targetNotes) { - const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); - if (reactionsCount === 0) { - myReactionsMap.set(note.id, null); - } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { - const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); - if (pairInBuffer) { - myReactionsMap.set(note.id, pairInBuffer[1]); - } else { - const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); - } - } else { - idsNeedFetchMyReaction.add(note.id); + } else { + if (note.reply) { + // idem for OP of a regular reply. + targetNotesMap.set(note.reply.id, note.reply); + } else if (note.replyId && options?.detail) { + targetNotesToFetch.push(note.replyId); } - } - 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); + targetNotesMap.set(note.id, note); } } - 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(x => x != null); - const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); - const users = [ - ...notes.map(({ user, userId }) => user ?? userId), - ...notes.map(({ replyUserId }) => replyUserId).filter(x => x != null), - ...notes.map(({ renoteUserId }) => renoteUserId).filter(x => x != null), - ]; - const packedUsers = await this.userEntityService.packMany(users, me) - .then(users => new Map(users.map(u => [u.id, u]))); + // Don't fetch notes that were added by ID and then found inline in another note. + for (let i = targetNotesToFetch.length - 1; i >= 0; i--) { + if (targetNotesMap.has(targetNotesToFetch[i])) { + targetNotesToFetch.splice(i, 1); + } + } + + // Populate any relations that weren't included in the source + if (targetNotesToFetch.length > 0) { + const newNotes = await this.notesRepository.find({ + where: { + id: In(targetNotesToFetch), + }, + relations: { + user: { + userProfile: true, + }, + reply: { + user: { + userProfile: true, + }, + }, + renote: { + user: { + userProfile: true, + }, + reply: { + user: { + userProfile: true, + }, + }, + }, + channel: true, + }, + }); + + for (const note of newNotes) { + targetNotesMap.set(note.id, note); + } + } + + const targetNotes = Array.from(targetNotesMap.values()); + const noteIds = Array.from(targetNotesMap.keys()); + + const usersMap = new Map(); + const allUsers = notes.flatMap(note => [ + note.user ?? note.userId, + note.reply?.user ?? note.replyUserId, + note.renote?.user ?? note.renoteUserId, + ]); + + for (const user of allUsers) { + if (!user) continue; + + if (typeof(user) === 'object') { + // ID -> Entity + usersMap.set(user.id, user); + } else if (!usersMap.has(user)) { + // ID -> ID + usersMap.set(user, user); + } + } + + const users = Array.from(usersMap.values()); + const userIds = Array.from(usersMap.keys()); + + const fileIds = new Set(targetNotes.flatMap(n => n.fileIds)); + const mentionedUsers = new Set(targetNotes.flatMap(note => note.mentions)); + + const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels] = await Promise.all([ + // bufferedReactions & myReactionsMap + this.getReactions(targetNotes, me), + // packedFiles + this.driveFileEntityService.packManyByIdsMap(Array.from(fileIds)), + // packedUsers + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + // mentionHandles + this.getUserHandles(Array.from(mentionedUsers)), + // userFollowings + this.cacheService.userFollowingsCache.fetchMany(userIds).then(fs => new Map(fs)), + // userBlockers + this.cacheService.userBlockedCache.fetchMany(userIds).then(bs => new Map(bs)), + // polls + this.pollsRepository.findBy({ noteId: In(noteIds) }) + .then(polls => new Map(polls.map(p => [p.noteId, p]))), + // pollVotes + this.pollVotesRepository.findBy({ noteId: In(noteIds), userId: In(userIds) }) + .then(votes => votes.reduce((noteMap, vote) => { + let userMap = noteMap.get(vote.noteId); + if (!userMap) { + userMap = new Map(); + noteMap.set(vote.noteId, userMap); + } + let voteList = userMap.get(vote.userId); + if (!voteList) { + voteList = []; + userMap.set(vote.userId, voteList); + } + voteList.push(vote); + return noteMap; + }, new Map>)), + // channels + this.getChannels(targetNotes), + // (not returned) + this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)), + ]); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, @@ -600,6 +694,13 @@ export class NoteEntityService implements OnModuleInit { myReactions: myReactionsMap, packedFiles, packedUsers, + mentionHandles, + userFollowings, + userBlockers, + polls, + pollVotes, + channels, + notes: new Map(targetNotes.map(n => [n.id, n])), }, }))); } @@ -635,4 +736,103 @@ export class NoteEntityService implements OnModuleInit { relations: ['user'], }); } + + private async getUserHandles(userIds: string[], hint?: Record): Promise> { + if (userIds.length < 1) return {}; + + // Hint is provided by packMany to avoid N+1 queries. + // It should already include all existing mentioned users. + if (hint) { + const handles = {} as Record; + for (const id of userIds) { + handles[id] = hint[id]; + } + return handles; + } + + const users = await this.usersRepository.find({ + select: { + id: true, + username: true, + host: true, + }, + where: { + id: In(userIds), + }, + }); + + return users.reduce((map, user) => { + map[user.id] = user.host + ? `@${user.username}@${user.host}` + : `@${user.username}`; + return map; + }, {} as Record); + } + + private async getChannels(notes: MiNote[]): Promise> { + const channels = new Map(); + const channelsToFetch = new Set(); + + for (const note of notes) { + if (note.channel) { + channels.set(note.channel.id, note.channel); + } else if (note.channelId) { + channelsToFetch.add(note.channelId); + } + } + + if (channelsToFetch.size > 0) { + const newChannels = await this.channelsRepository.findBy({ + id: In(Array.from(channelsToFetch)), + }); + for (const channel of newChannels) { + channels.set(channel.id, channel); + } + } + + return channels; + } + + private async getReactions(notes: MiNote[], me: { id: string } | null | undefined) { + const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; + + const meId = me ? me.id : null; + const myReactionsMap = new Map(); + if (meId) { + const idsNeedFetchMyReaction = new Set(); + + for (const note of notes) { + const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); + if (reactionsCount === 0) { + myReactionsMap.set(note.id, null); + } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); + if (pairInBuffer) { + myReactionsMap.set(note.id, pairInBuffer[1]); + } else { + const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); + myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); + } + } else { + idsNeedFetchMyReaction.add(note.id); + } + } + + 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); + } + } + + return { bufferedReactions, myReactionsMap }; + } + + @bindThis + public genLocalNoteUri(noteId: string): string { + return `${this.config.url}/notes/${noteId}`; + } } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index cea674d96c..cc8edfc666 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -16,17 +16,26 @@ import { bindThis } from '@/decorators.js'; import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js'; import { CacheService } from '@/core/CacheService.js'; import { RoleEntityService } from './RoleEntityService.js'; +import { ChatEntityService } from './ChatEntityService.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', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]); +function undefOnMissing(packPromise: Promise): Promise { + return packPromise.catch(err => { + if (err instanceof EntityNotFoundError) return undefined; + throw err; + }); +} + @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; private roleEntityService: RoleEntityService; + private chatEntityService: ChatEntityService; constructor( private moduleRef: ModuleRef, @@ -41,9 +50,6 @@ export class NotificationEntityService implements OnModuleInit { private followRequestsRepository: FollowRequestsRepository, private cacheService: CacheService, - - //private userEntityService: UserEntityService, - //private noteEntityService: NoteEntityService, ) { } @@ -51,6 +57,7 @@ export class NotificationEntityService implements OnModuleInit { this.userEntityService = this.moduleRef.get('UserEntityService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.roleEntityService = this.moduleRef.get('RoleEntityService'); + this.chatEntityService = this.moduleRef.get('ChatEntityService'); } /** @@ -59,7 +66,6 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - options: { checkValidNotifier?: boolean; }, @@ -76,9 +82,9 @@ export class NotificationEntityService implements OnModuleInit { const noteIfNeed = needsNote ? ( hint?.packedNotes != null ? hint.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.noteId, { id: meId }, { + : undefOnMissing(this.noteEntityService.pack(notification.noteId, { id: meId }, { detail: true, - }) + })) ) : undefined; // if the note has been deleted, don't show this notification if (needsNote && !noteIfNeed) return null; @@ -87,17 +93,17 @@ export class NotificationEntityService implements OnModuleInit { const userIfNeed = needsUser ? ( hint?.packedUsers != null ? hint.packedUsers.get(notification.notifierId) - : this.userEntityService.pack(notification.notifierId, { id: meId }) + : undefOnMissing(this.userEntityService.pack(notification.notifierId, { id: meId })) ) : undefined; // if the user has been deleted, don't show this notification if (needsUser && !userIfNeed) return null; - // #region Grouped notifications + //#region Grouped notifications 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 }); + : await undefOnMissing(this.userEntityService.pack(reaction.userId, { id: meId })); return { user, reaction: reaction.reaction, @@ -122,7 +128,7 @@ export class NotificationEntityService implements OnModuleInit { return packedUser; } - return this.userEntityService.pack(userId, { id: meId }); + return undefOnMissing(this.userEntityService.pack(userId, { id: meId })); }))).filter(x => x != null); // if all users have been deleted, don't show this notification if (users.length === 0) { @@ -137,20 +143,24 @@ export class NotificationEntityService implements OnModuleInit { users, }); } - // #endregion + //#endregion const needsRole = notification.type === 'roleAssigned'; const role = needsRole - ? await this.roleEntityService.pack(notification.roleId).catch(err => { - if (err instanceof EntityNotFoundError) return undefined; - throw err; - }) + ? await undefOnMissing(this.roleEntityService.pack(notification.roleId)) : undefined; // if the role has been deleted, don't show this notification if (needsRole && !role) { return null; } + const needsChatRoomInvitation = notification.type === 'chatRoomInvitationReceived'; + const chatRoomInvitation = needsChatRoomInvitation ? await this.chatEntityService.packRoomInvitation(notification.invitationId, { id: meId }).catch(() => null) : undefined; + // if the invitation has been deleted, don't show this notification + if (needsChatRoomInvitation && !chatRoomInvitation) { + return null; + } + return await awaitAll({ id: notification.id, createdAt: new Date(notification.createdAt).toISOString(), @@ -164,6 +174,9 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'roleAssigned' ? { role: role, } : {}), + ...(notification.type === 'chatRoomInvitationReceived' ? { + invitation: chatRoomInvitation, + } : {}), ...(notification.type === 'followRequestAccepted' ? { message: notification.message, } : {}), diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 2a7dc37bce..3fa38c9521 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -13,6 +13,7 @@ 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'; +import { Packed } from '@/misc/json-schema.js'; @Injectable() export class RoleEntityService { @@ -31,7 +32,7 @@ export class RoleEntityService { public async pack( src: MiRole['id'] | MiRole, me?: { id: MiUser['id'] } | null | undefined, - ) { + ): Promise> { const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') @@ -67,6 +68,7 @@ export class RoleEntityService { isModerator: role.isModerator, isExplorable: role.isExplorable, asBadge: role.asBadge, + preserveAssignmentOnMoveAccount: role.preserveAssignmentOnMoveAccount, canEditMembersByModerator: role.canEditMembersByModerator, displayOrder: role.displayOrder, policies: policies, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 96fef863a0..638eaac16f 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -30,10 +30,11 @@ import type { FollowingsRepository, FollowRequestsRepository, MiFollowing, + MiInstance, + MiMeta, MiUserNotePining, MiUserProfile, MutingsRepository, - NoteUnreadsRepository, RenoteMutingsRepository, UserMemoRepository, UserNotePiningsRepository, @@ -42,17 +43,19 @@ import type { UsersRepository, } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; +import { RolePolicies, 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 { ChatService } from '@/core/ChatService.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import type { CacheService } from '@/core/CacheService.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'; /* eslint-disable @typescript-eslint/no-non-null-assertion */ @@ -62,19 +65,21 @@ 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); } export type UserRelation = { id: MiUser['id'] - following: MiFollowing | null, + following: Omit | null, isFollowing: boolean isFollowed: boolean hasPendingFollowRequestFromYou: boolean @@ -85,7 +90,7 @@ export type UserRelation = { isRenoteMuted: boolean isInstanceMuted?: boolean memo?: string | null -} +}; @Injectable() export class UserEntityService implements OnModuleInit { @@ -99,6 +104,8 @@ export class UserEntityService implements OnModuleInit { private federatedInstanceService: FederatedInstanceService; private idService: IdService; private avatarDecorationService: AvatarDecorationService; + private chatService: ChatService; + private cacheService: CacheService; constructor( private moduleRef: ModuleRef, @@ -106,6 +113,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.redis) private redisClient: Redis.Redis, @@ -133,9 +143,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, @@ -158,6 +165,8 @@ export class UserEntityService implements OnModuleInit { this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.idService = this.moduleRef.get('IdService'); this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); + this.chatService = this.moduleRef.get('ChatService'); + this.cacheService = this.moduleRef.get('CacheService'); } //#region Validators @@ -188,16 +197,8 @@ export class UserEntityService implements OnModuleInit { memo, mutedInstances, ] = await Promise.all([ - this.followingsRepository.findOneBy({ - followerId: me, - followeeId: target, - }), - this.followingsRepository.exists({ - where: { - followerId: target, - followeeId: me, - }, - }), + this.cacheService.userFollowingsCache.fetch(me).then(f => f.get(target) ?? null), + this.cacheService.userFollowingsCache.fetch(target).then(f => f.has(me)), this.followRequestsRepository.exists({ where: { followerId: me, @@ -210,45 +211,22 @@ export class UserEntityService implements OnModuleInit { followeeId: me, }, }), - this.blockingsRepository.exists({ - where: { - blockerId: me, - blockeeId: target, - }, - }), - this.blockingsRepository.exists({ - where: { - blockerId: target, - blockeeId: me, - }, - }), - this.mutingsRepository.exists({ - where: { - muterId: me, - muteeId: target, - }, - }), - this.renoteMutingsRepository.exists({ - where: { - muterId: me, - muteeId: target, - }, - }), - this.usersRepository.createQueryBuilder('u') - .select('u.host') - .where({ id: target }) - .getRawOne<{ u_host: string }>() - .then(it => it?.u_host ?? null), + this.cacheService.userBlockingCache.fetch(me) + .then(blockees => blockees.has(target)), + this.cacheService.userBlockedCache.fetch(me) + .then(blockers => blockers.has(target)), + this.cacheService.userMutingsCache.fetch(me) + .then(mutings => mutings.has(target)), + this.cacheService.renoteMutingsCache.fetch(me) + .then(mutings => mutings.has(target)), + this.cacheService.findUserById(target).then(u => u.host), this.userMemosRepository.createQueryBuilder('m') .select('m.memo') .where({ userId: me, targetUserId: target }) .getRawOne<{ m_memo: string | null }>() .then(it => it?.m_memo ?? null), - this.userProfilesRepository.createQueryBuilder('p') - .select('p.mutedInstances') - .where({ userId: me }) - .getRawOne<{ p_mutedInstances: string[] }>() - .then(it => it?.p_mutedInstances ?? []), + this.cacheService.userProfileCache.fetch(me) + .then(profile => profile.mutedInstances), ]); const isInstanceMuted = !!host && mutedInstances.includes(host); @@ -272,8 +250,8 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getRelations(me: MiUser['id'], targets: MiUser['id'][]): Promise> { const [ - followers, - followees, + myFollowing, + myFollowers, followersRequests, followeesRequests, blockers, @@ -284,13 +262,8 @@ export class UserEntityService implements OnModuleInit { memos, mutedInstances, ] = await Promise.all([ - this.followingsRepository.findBy({ followerId: me }) - .then(f => new Map(f.map(it => [it.followeeId, it]))), - this.followingsRepository.createQueryBuilder('f') - .select('f.followerId') - .where('f.followeeId = :me', { me }) - .getRawMany<{ f_followerId: string }>() - .then(it => it.map(it => it.f_followerId)), + this.cacheService.userFollowingsCache.fetch(me), + this.cacheService.userFollowersCache.fetch(me), this.followRequestsRepository.createQueryBuilder('f') .select('f.followeeId') .where('f.followerId = :me', { me }) @@ -301,34 +274,18 @@ export class UserEntityService implements OnModuleInit { .where('f.followeeId = :me', { me }) .getRawMany<{ f_followerId: string }>() .then(it => it.map(it => it.f_followerId)), - this.blockingsRepository.createQueryBuilder('b') - .select('b.blockeeId') - .where('b.blockerId = :me', { me }) - .getRawMany<{ b_blockeeId: string }>() - .then(it => it.map(it => it.b_blockeeId)), - this.blockingsRepository.createQueryBuilder('b') - .select('b.blockerId') - .where('b.blockeeId = :me', { me }) - .getRawMany<{ b_blockerId: string }>() - .then(it => it.map(it => it.b_blockerId)), - this.mutingsRepository.createQueryBuilder('m') - .select('m.muteeId') - .where('m.muterId = :me', { me }) - .getRawMany<{ m_muteeId: string }>() - .then(it => it.map(it => it.m_muteeId)), - this.renoteMutingsRepository.createQueryBuilder('m') - .select('m.muteeId') - .where('m.muterId = :me', { me }) - .getRawMany<{ m_muteeId: string }>() - .then(it => it.map(it => it.m_muteeId)), - this.usersRepository.createQueryBuilder('u') - .select(['u.id', 'u.host']) - .where({ id: In(targets) } ) - .getRawMany<{ m_id: string, m_host: string }>() - .then(it => it.reduce((map, it) => { - map[it.m_id] = it.m_host; - return map; - }, {} as Record)), + this.cacheService.userBlockedCache.fetch(me), + this.cacheService.userBlockingCache.fetch(me), + this.cacheService.userMutingsCache.fetch(me), + this.cacheService.renoteMutingsCache.fetch(me), + this.cacheService.getUsers(targets) + .then(users => { + const record: Record = {}; + for (const [id, user] of users) { + record[id] = user.host; + } + return record; + }), this.userMemosRepository.createQueryBuilder('m') .select(['m.targetUserId', 'm.memo']) .where({ userId: me, targetUserId: In(targets) }) @@ -337,16 +294,13 @@ export class UserEntityService implements OnModuleInit { map[it.m_targetUserId] = it.m_memo; return map; }, {} as Record)), - this.userProfilesRepository.createQueryBuilder('p') - .select('p.mutedInstances') - .where({ userId: me }) - .getRawOne<{ p_mutedInstances: string[] }>() - .then(it => it?.p_mutedInstances ?? []), + this.cacheService.userProfileCache.fetch(me) + .then(p => p.mutedInstances), ]); return new Map( targets.map(target => { - const following = followers.get(target) ?? null; + const following = myFollowing.get(target) ?? null; return [ target, @@ -354,14 +308,14 @@ export class UserEntityService implements OnModuleInit { id: target, following: following, isFollowing: following != null, - isFollowed: followees.includes(target), + isFollowed: myFollowers.has(target), hasPendingFollowRequestFromYou: followersRequests.includes(target), hasPendingFollowRequestToYou: followeesRequests.includes(target), - isBlocking: blockers.includes(target), - isBlocked: blockees.includes(target), - isMuted: muters.includes(target), - isRenoteMuted: renoteMuters.includes(target), - isInstanceMuted: mutedInstances.includes(hosts[target]), + isBlocking: blockees.has(target), + isBlocked: blockers.has(target), + isMuted: muters.has(target), + isRenoteMuted: renoteMuters.has(target), + isInstanceMuted: hosts[target] != null && mutedInstances.includes(hosts[target]), memo: memos[target] ?? null, }, ]; @@ -386,6 +340,7 @@ export class UserEntityService implements OnModuleInit { return false; // TODO } + // TODO optimization: make redis calls in MULTI @bindThis public async getNotificationsInfo(userId: MiUser['id']): Promise<{ hasUnread: boolean; @@ -419,16 +374,14 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getHasPendingReceivedFollowRequest(userId: MiUser['id']): Promise { - const count = await this.followRequestsRepository.countBy({ + return await this.followRequestsRepository.existsBy({ followeeId: userId, }); - - return count > 0; } @bindThis public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise { - return this.followRequestsRepository.existsBy({ + return await this.followRequestsRepository.existsBy({ followerId: userId, }); } @@ -447,7 +400,11 @@ export class UserEntityService implements OnModuleInit { @bindThis public getIdenticonUrl(user: MiUser): string { - return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; + if ((user.host == null || user.host === this.config.host) && user.username.includes('.') && this.meta.iconUrl) { // ローカルのシステムアカウントの場合 + return this.meta.iconUrl; + } else { + return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; + } } @bindThis @@ -471,6 +428,10 @@ export class UserEntityService implements OnModuleInit { userRelations?: Map, userMemos?: Map, pinNotes?: Map, + iAmModerator?: boolean, + userIdsByUri?: Map, + instances?: Map, + securityKeyCounts?: Map, }, ): Promise> { const opts = Object.assign({ @@ -478,7 +439,10 @@ export class UserEntityService implements OnModuleInit { includeSecrets: false, }, options); - const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); + const user = typeof src === 'object' ? src : await this.usersRepository.findOneOrFail({ + where: { id: src }, + relations: { userProfile: true }, + }); // migration if (user.avatarId != null && user.avatarUrl === null) { @@ -509,10 +473,10 @@ export class UserEntityService implements OnModuleInit { const isDetailed = opts.schema !== 'UserLite'; const meId = me ? me.id : null; const isMe = meId === user.id; - const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; + const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me as MiUser) : false); const profile = isDetailed - ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) + ? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null; let relation: UserRelation | null = null; @@ -547,7 +511,7 @@ export class UserEntityService implements OnModuleInit { } } - const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; + const mastoapi = !isDetailed ? opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; const followingCount = profile == null ? null : (profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount : @@ -570,13 +534,16 @@ export class UserEntityService implements OnModuleInit { const checkHost = user.host == null ? this.config.host : user.host; const notificationsInfo = isMe && isDetailed ? await this.getNotificationsInfo(user.id) : null; + let fetchPoliciesPromise: Promise | null = null; + const fetchPolicies = () => fetchPoliciesPromise ??= this.roleService.getUserPolicies(user); + const packed = { id: user.id, name: user.name, username: user.username, host: user.host, - avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), - avatarBlurhash: user.avatarBlurhash, + avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user), + avatarBlurhash: (user.avatarId == null ? null : 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 => ({ @@ -594,19 +561,21 @@ export class UserEntityService implements OnModuleInit { enableRss: user.enableRss, mandatoryCW: user.mandatoryCW, rejectQuotes: user.rejectQuotes, - isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), + attributionDomains: user.attributionDomains, + isSilenced: user.isSilenced || fetchPolicies().then(r => !r.canPublicNote), speakAsCat: user.speakAsCat ?? false, approved: user.approved, requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true, makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined, makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined, - instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { + instance: user.host ? Promise.resolve(opts.instances?.has(user.host) ? opts.instances.get(user.host) : this.federatedInstanceService.fetch(user.host)).then(instance => instance ? { name: instance.name, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, + isSilenced: instance.isSilenced, } : undefined) : undefined, followersCount: followersCount ?? 0, followingCount: followingCount ?? 0, @@ -614,7 +583,7 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs + badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user).then((rs) => rs .filter((r) => r.isPublic || iAmModerator) .sort((a, b) => b.displayOrder - a.displayOrder) .map((r) => ({ @@ -627,17 +596,17 @@ export class UserEntityService implements OnModuleInit { ...(isDetailed ? { url: profile!.url, uri: user.uri, - movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, + movedTo: user.movedToUri ? Promise.resolve(opts.userIdsByUri?.get(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))) + ? Promise.all(user.alsoKnownAs.map(uri => Promise.resolve(opts.userIdsByUri?.get(uri) ?? this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))) .then(xs => xs.length === 0 ? null : xs.filter(x => x != null)) : 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, + bannerUrl: user.bannerId == null ? null : user.bannerUrl, + bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash, + backgroundUrl: user.backgroundId == null ? null : user.backgroundUrl, + backgroundBlurhash: user.backgroundId == null ? null : user.backgroundBlurhash, isLocked: user.isLocked, isSuspended: user.isSuspended, location: profile!.location, @@ -655,7 +624,9 @@ export class UserEntityService implements OnModuleInit { publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964 followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, - roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ + chatScope: user.chatScope, + canChat: fetchPolicies().then(r => r.chatAvailability === 'available'), + roles: this.roleService.getUserRoles(user).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, @@ -673,7 +644,7 @@ export class UserEntityService implements OnModuleInit { twoFactorEnabled: profile!.twoFactorEnabled, usePasswordLessLogin: profile!.usePasswordLessLogin, securityKeys: profile!.twoFactorEnabled - ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) + ? Promise.resolve(opts.securityKeyCounts?.get(user.id) ?? this.userSecurityKeysRepository.countBy({ userId: user.id })).then(result => result >= 1) : false, } : {}), @@ -698,14 +669,9 @@ export class UserEntityService implements OnModuleInit { 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), + hasUnreadSpecifiedNotes: false, // 後方互換性のため + hasUnreadMentions: false, // 後方互換性のため + hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id), hasUnreadAnnouncement: unreadAnnouncements!.length > 0, unreadAnnouncements, hasUnreadAntenna: this.getHasUnreadAntenna(user.id), @@ -722,9 +688,10 @@ export class UserEntityService implements OnModuleInit { emailNotificationTypes: profile!.emailNotificationTypes, achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, - policies: this.roleService.getUserPolicies(user.id), + policies: fetchPolicies(), defaultCW: profile!.defaultCW, defaultCWPriority: profile!.defaultCWPriority, + allowUnsignedFetch: user.allowUnsignedFetch, } : {}), ...(opts.includeSecrets ? { @@ -771,57 +738,103 @@ export class UserEntityService implements OnModuleInit { includeSecrets?: boolean, }, ): Promise[]> { + if (users.length === 0) return []; + // -- IDのみの要素を補完して完全なエンティティ一覧を作る const _users = users.filter((user): user is MiUser => typeof user !== 'string'); if (_users.length !== users.length) { _users.push( - ...await this.usersRepository.findBy({ - id: In(users.filter((user): user is string => typeof user === 'string')), + ...await this.usersRepository.find({ + where: { + id: In(users.filter((user): user is string => typeof user === 'string')), + }, + relations: { + userProfile: true, + }, }), ); } const _userIds = _users.map(u => u.id); - // -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得 + const iAmModerator = await this.roleService.isModerator(me as MiUser); + const meId = me ? me.id : null; + const isDetailed = options && options.schema !== 'UserLite'; + const isDetailedAndMod = isDetailed && iAmModerator; - let profilesMap: Map = new Map(); - let userRelations: Map = new Map(); - let userMemos: Map = new Map(); - let pinNotes: Map = new Map(); + const userUris = new Set(_users + .flatMap(user => [user.uri, user.movedToUri]) + .filter((uri): uri is string => uri != null)); - if (options?.schema !== 'UserLite') { - profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) }) - .then(profiles => new Map(profiles.map(p => [p.userId, p]))); + const userHosts = new Set(_users + .map(user => user.host) + .filter((host): host is string => host != null)); - const meId = me ? me.id : null; - if (meId) { - userMemos = await this.userMemosRepository.findBy({ userId: meId }) - .then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))); - - if (_userIds.length > 0) { - userRelations = await this.getRelations(meId, _userIds); - pinNotes = await this.userNotePiningsRepository.createQueryBuilder('pin') - .where('pin.userId IN (:...userIds)', { userIds: _userIds }) - .innerJoinAndSelect('pin.note', 'note') - .getMany() - .then(pinsNotes => { - const map = new Map(); - for (const note of pinsNotes) { - const notes = map.get(note.userId) ?? []; - notes.push(note); - map.set(note.userId, notes); - } - for (const [, notes] of map.entries()) { - // pack側ではDESCで取得しているので、それに合わせて降順に並び替えておく - notes.sort((a, b) => b.id.localeCompare(a.id)); - } - return map; - }); - } + const _profilesFromUsers: [string, MiUserProfile][] = []; + const _profilesToFetch: string[] = []; + for (const user of _users) { + if (user.userProfile) { + _profilesFromUsers.push([user.id, user.userProfile]); + } else { + _profilesToFetch.push(user.id); } } + // -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得 + + const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts] = await Promise.all([ + // profilesMap + this.cacheService.userProfileCache.fetchMany(_profilesToFetch).then(profiles => new Map(profiles.concat(_profilesFromUsers))), + // userMemos + isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId }) + .then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(), + // userRelations + isDetailed && meId ? this.getRelations(meId, _userIds) : new Map(), + // pinNotes + isDetailed ? this.userNotePiningsRepository.createQueryBuilder('pin') + .where('pin.userId IN (:...userIds)', { userIds: _userIds }) + .innerJoinAndSelect('pin.note', 'note') + .getMany() + .then(pinsNotes => { + const map = new Map(); + for (const note of pinsNotes) { + const notes = map.get(note.userId) ?? []; + notes.push(note); + map.set(note.userId, notes); + } + for (const [, notes] of map.entries()) { + // pack側ではDESCで取得しているので、それに合わせて降順に並び替えておく + notes.sort((a, b) => b.id.localeCompare(a.id)); + } + return map; + }) : new Map(), + // userIdsByUrl + isDetailed ? this.usersRepository.createQueryBuilder('user') + .select([ + 'user.id', + 'user.uri', + ]) + .where({ + uri: In(Array.from(userUris)), + }) + .getRawMany<{ user_uri: string, user_id: string }>() + .then(users => new Map(users.map(u => [u.user_uri, u.user_id]))) : new Map(), + // instances + Promise.all(Array.from(userHosts).map(async host => [host, await this.federatedInstanceService.fetch(host)] as const)) + .then(hosts => new Map(hosts)), + // securityKeyCounts + isDetailedAndMod ? this.userSecurityKeysRepository.createQueryBuilder('key') + .select('key.userId', 'userId') + .addSelect('count(key.id)', 'userCount') + .where({ + userId: In(_userIds), + }) + .groupBy('key.userId') + .getRawMany<{ userId: string, userCount: number }>() + .then(counts => new Map(counts.map(c => [c.userId, c.userCount]))) + : undefined, // .pack will fetch the keys for the requesting user if it's in the _userIds + ]); + return Promise.all( _users.map(u => this.pack( u, @@ -832,6 +845,10 @@ export class UserEntityService implements OnModuleInit { userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes, + iAmModerator, + userIdsByUri, + instances, + securityKeyCounts, }, )), ); diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 461fcf11c3..099d48c81a 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -29,7 +29,6 @@ export const DI = { noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), - noteUnreadsRepository: Symbol('noteUnreadsRepository'), pollsRepository: Symbol('pollsRepository'), pollVotesRepository: Symbol('pollVotesRepository'), userProfilesRepository: Symbol('userProfilesRepository'), @@ -79,6 +78,7 @@ export const DI = { registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), systemWebhooksRepository: Symbol('systemWebhooksRepository'), + systemAccountsRepository: Symbol('systemAccountsRepository'), adsRepository: Symbol('adsRepository'), passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'), retentionAggregationsRepository: Symbol('retentionAggregationsRepository'), @@ -87,6 +87,11 @@ export const DI = { flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), userMemosRepository: Symbol('userMemosRepository'), + chatMessagesRepository: Symbol('chatMessagesRepository'), + chatApprovalsRepository: Symbol('chatApprovalsRepository'), + chatRoomsRepository: Symbol('chatRoomsRepository'), + chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'), + chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'), noteEditRepository: Symbol('noteEditRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index ba44cfa2e6..9a50eb8561 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -11,6 +11,7 @@ const envOption = { verbose: false, withLogTime: false, quiet: false, + hideWorkerId: false, }; for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) { diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index eb2b081220..4bf45fc76b 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -19,23 +19,35 @@ type Context = { type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; export type Data = DataElement | DataElement[]; -export type DataElement = Record | Error | string | null; +export type DataElement = DataObject | Error | string | null; +// https://stackoverflow.com/questions/61148466/typescript-type-that-matches-any-object-but-not-arrays +export type DataObject = Record | (object & { length?: never; }); + +const levelFuncs = { + error: 'error', + warning: 'warn', + success: 'info', + info: 'log', + debug: 'debug', +} as const satisfies Record; // eslint-disable-next-line import/no-default-export export default class Logger { private context: Context; private parentLogger: Logger | null = null; + public readonly verbose: boolean; - constructor(context: string, color?: KEYWORD) { + constructor(context: string, color?: KEYWORD, verbose?: boolean) { this.context = { name: context, color: color, }; + this.verbose = verbose ?? envOption.verbose; } @bindThis public createSubLogger(context: string, color?: KEYWORD): Logger { - const logger = new Logger(context, color); + const logger = new Logger(context, color, this.verbose); logger.parentLogger = this; return logger; } @@ -67,7 +79,9 @@ export default class Logger { level === 'info' ? message : null; - let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`; + let log = envOption.hideWorkerId + ? `${l}\t[${contexts.join(' ')}]\t\t${m}` + : `${l} ${worker}\t[${contexts.join(' ')}]\t\t${m}`; if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; const args: unknown[] = [important ? chalk.bold(log) : log]; @@ -80,7 +94,7 @@ export default class Logger { } else if (data != null) { args.push(data); } - console.log(...args); + console[levelFuncs[level]](...args); } @bindThis @@ -108,7 +122,7 @@ export default class Logger { @bindThis public debug(message: string, data?: Data, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報) - if (process.env.NODE_ENV !== 'production' || envOption.verbose) { + if (process.env.NODE_ENV !== 'production' || this.verbose) { this.log('debug', message, data, important); } } diff --git a/packages/backend/src/misc/FileWriterStream.ts b/packages/backend/src/misc/FileWriterStream.ts index 367a8eb560..a61d949ef4 100644 --- a/packages/backend/src/misc/FileWriterStream.ts +++ b/packages/backend/src/misc/FileWriterStream.ts @@ -4,6 +4,7 @@ */ import * as fs from 'node:fs/promises'; +import { WritableStream } from 'node:stream/web'; import type { PathLike } from 'node:fs'; /** @@ -20,7 +21,7 @@ export class FileWriterStream extends WritableStream { write: async (chunk, controller) => { if (file === null) { controller.error(); - throw new Error(); + throw new Error('file is null'); } await file.write(chunk); diff --git a/packages/backend/src/misc/QuantumKVCache.ts b/packages/backend/src/misc/QuantumKVCache.ts new file mode 100644 index 0000000000..b96937d6f2 --- /dev/null +++ b/packages/backend/src/misc/QuantumKVCache.ts @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { InternalEventService } from '@/core/InternalEventService.js'; +import { bindThis } from '@/decorators.js'; +import { InternalEventTypes } from '@/core/GlobalEventService.js'; +import { MemoryKVCache } from '@/misc/cache.js'; + +export interface QuantumKVOpts { + /** + * Memory cache lifetime in milliseconds. + */ + lifetime: number; + + /** + * Callback to fetch the value for a key that wasn't found in the cache. + * May be synchronous or async. + */ + fetcher: (key: string, cache: QuantumKVCache) => T | Promise; + + /** + * Optional callback to fetch the value for multiple keys that weren't found in the cache. + * May be synchronous or async. + * If not provided, then the implementation will fall back on repeated calls to fetcher(). + */ + bulkFetcher?: (keys: string[], cache: QuantumKVCache) => Iterable<[key: string, value: T]> | Promise>; + + /** + * Optional callback when one or more values are changed (created, updated, or deleted) in the cache, either locally or elsewhere in the cluster. + * This is called *after* the cache state is updated. + * Implementations may be synchronous or async. + */ + onChanged?: (keys: string[], cache: QuantumKVCache) => void | Promise; +} + +/** + * QuantumKVCache is a lifetime-bounded memory cache (like MemoryKVCache) with automatic cross-cluster synchronization via Redis. + * All nodes in the cluster are guaranteed to have a *subset* view of the current accurate state, though individual processes may have different items in their local cache. + * This ensures that a call to get() will never return stale data. + */ +export class QuantumKVCache implements Iterable<[key: string, value: T]> { + private readonly memoryCache: MemoryKVCache; + + public readonly fetcher: QuantumKVOpts['fetcher']; + public readonly bulkFetcher: QuantumKVOpts['bulkFetcher']; + public readonly onChanged: QuantumKVOpts['onChanged']; + + /** + * @param internalEventService Service bus to synchronize events. + * @param name Unique name of the cache - must be the same in all processes. + * @param opts Cache options + */ + constructor( + private readonly internalEventService: InternalEventService, + private readonly name: string, + opts: QuantumKVOpts, + ) { + this.memoryCache = new MemoryKVCache(opts.lifetime); + this.fetcher = opts.fetcher; + this.bulkFetcher = opts.bulkFetcher; + this.onChanged = opts.onChanged; + + this.internalEventService.on('quantumCacheUpdated', this.onQuantumCacheUpdated, { + // Ignore our own events, otherwise we'll immediately erase any set value. + ignoreLocal: true, + }); + } + + /** + * The number of items currently in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + public get size() { + return this.memoryCache.size; + } + + /** + * Iterates all [key, value] pairs in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + @bindThis + public *entries(): Generator<[key: string, value: T]> { + for (const entry of this.memoryCache.entries) { + yield [entry[0], entry[1].value]; + } + } + + /** + * Iterates all keys in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + @bindThis + public *keys() { + for (const entry of this.memoryCache.entries) { + yield entry[0]; + } + } + + /** + * Iterates all values pairs in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + @bindThis + public *values() { + for (const entry of this.memoryCache.entries) { + yield entry[1].value; + } + } + + /** + * Creates or updates a value in the cache, and erases any stale caches across the cluster. + * Fires an onSet event after the cache has been updated in all processes. + * Skips if the value is unchanged. + */ + @bindThis + public async set(key: string, value: T): Promise { + if (this.memoryCache.get(key) === value) { + return; + } + + this.memoryCache.set(key, value); + + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: [key] }); + + if (this.onChanged) { + await this.onChanged([key], this); + } + } + + /** + * Creates or updates multiple value in the cache, and erases any stale caches across the cluster. + * Fires an onSet for each changed item event after the cache has been updated in all processes. + * Skips if all values are unchanged. + */ + @bindThis + public async setMany(items: Iterable<[key: string, value: T]>): Promise { + const changedKeys: string[] = []; + + for (const item of items) { + if (this.memoryCache.get(item[0]) !== item[1]) { + changedKeys.push(item[0]); + this.memoryCache.set(item[0], item[1]); + } + } + + if (changedKeys.length > 0) { + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: changedKeys }); + + if (this.onChanged) { + await this.onChanged(changedKeys, this); + } + } + } + + /** + * Adds a value to the local memory cache without notifying other process. + * Neither a Redis event nor onSet callback will be fired, as the value has not actually changed. + * This should only be used when the value is known to be current, like after fetching from the database. + */ + @bindThis + public add(key: string, value: T): void { + this.memoryCache.set(key, value); + } + + /** + * Adds multiple values to the local memory cache without notifying other process. + * Neither a Redis event nor onSet callback will be fired, as the value has not actually changed. + * This should only be used when the value is known to be current, like after fetching from the database. + */ + @bindThis + public addMany(items: Iterable<[key: string, value: T]>): void { + for (const [key, value] of items) { + this.memoryCache.set(key, value); + } + } + + /** + * Gets a value from the local memory cache, or returns undefined if not found. + * Returns cached data only - does not make any fetches. + */ + @bindThis + public get(key: string): T | undefined { + return this.memoryCache.get(key); + } + + /** + * Gets multiple values from the local memory cache; returning undefined for any missing keys. + * Returns cached data only - does not make any fetches. + */ + @bindThis + public getMany(keys: Iterable): [key: string, value: T | undefined][] { + const results: [key: string, value: T | undefined][] = []; + for (const key of keys) { + results.push([key, this.get(key)]); + } + return results; + } + + /** + * Gets or fetches a value from the cache. + * Fires an onSet event, but does not emit an update event to other processes. + */ + @bindThis + public async fetch(key: string): Promise { + let value = this.memoryCache.get(key); + if (value === undefined) { + value = await this.fetcher(key, this); + this.memoryCache.set(key, value); + + if (this.onChanged) { + await this.onChanged([key], this); + } + } + return value; + } + + /** + * Gets or fetches multiple values from the cache. + * Fires onSet events, but does not emit any update events to other processes. + */ + @bindThis + public async fetchMany(keys: Iterable): Promise<[key: string, value: T][]> { + const results: [key: string, value: T][] = []; + const toFetch: string[] = []; + + // Spliterate into cached results / uncached keys. + for (const key of keys) { + const fromCache = this.get(key); + if (fromCache) { + results.push([key, fromCache]); + } else { + toFetch.push(key); + } + } + + // Fetch any uncached keys + if (toFetch.length > 0) { + const fetched = await this.bulkFetch(toFetch); + + // Add to cache and return set + this.addMany(fetched); + results.push(...fetched); + + // Emit event + if (this.onChanged) { + await this.onChanged(toFetch, this); + } + } + + return results; + } + + /** + * Returns true is a key exists in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + @bindThis + public has(key: string): boolean { + return this.memoryCache.get(key) !== undefined; + } + + /** + * Deletes a value from the cache, and erases any stale caches across the cluster. + * Fires an onDelete event after the cache has been updated in all processes. + */ + @bindThis + public async delete(key: string): Promise { + this.memoryCache.delete(key); + + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: [key] }); + + if (this.onChanged) { + await this.onChanged([key], this); + } + } + /** + * Deletes multiple values from the cache, and erases any stale caches across the cluster. + * Fires an onDelete event for each key after the cache has been updated in all processes. + * Skips if the input is empty. + */ + @bindThis + public async deleteMany(keys: Iterable): Promise { + const deleted: string[] = []; + + for (const key of keys) { + this.memoryCache.delete(key); + deleted.push(key); + } + + if (deleted.length === 0) { + return; + } + + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: deleted }); + + if (this.onChanged) { + await this.onChanged(deleted, this); + } + } + + /** + * Refreshes the value of a key from the fetcher, and erases any stale caches across the cluster. + * Fires an onSet event after the cache has been updated in all processes. + */ + @bindThis + public async refresh(key: string): Promise { + const value = await this.fetcher(key, this); + await this.set(key, value); + return value; + } + + @bindThis + public async refreshMany(keys: Iterable): Promise<[key: string, value: T][]> { + const values = await this.bulkFetch(keys); + await this.setMany(values); + return values; + } + + /** + * Erases all entries from the local memory cache. + * Does not send any events or update other processes. + */ + @bindThis + public clear() { + this.memoryCache.clear(); + } + + /** + * Removes expired cache entries from the local view. + * Does not send any events or update other processes. + */ + @bindThis + public gc() { + this.memoryCache.gc(); + } + + /** + * Erases all data and disconnects from the cluster. + * This *must* be called when shutting down to prevent memory leaks! + */ + @bindThis + public dispose() { + this.internalEventService.off('quantumCacheUpdated', this.onQuantumCacheUpdated); + + this.memoryCache.dispose(); + } + + @bindThis + private async bulkFetch(keys: Iterable): Promise<[key: string, value: T][]> { + if (this.bulkFetcher) { + const results = await this.bulkFetcher(Array.from(keys), this); + return Array.from(results); + } + + const results: [key: string, value: T][] = []; + for (const key of keys) { + const value = await this.fetcher(key, this); + results.push([key, value]); + } + return results; + } + + @bindThis + private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise { + if (data.name === this.name) { + for (const key of data.keys) { + this.memoryCache.delete(key); + } + + if (this.onChanged) { + await this.onChanged(data.keys, this); + } + } + } + + /** + * Iterates all [key, value] pairs in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + [Symbol.iterator](): Iterator<[key: string, value: T]> { + return this.entries(); + } +} diff --git a/packages/backend/src/misc/append-content-warning.ts b/packages/backend/src/misc/append-content-warning.ts index 152cd6760e..9f61776b1d 100644 --- a/packages/backend/src/misc/append-content-warning.ts +++ b/packages/backend/src/misc/append-content-warning.ts @@ -14,10 +14,13 @@ * @param additional Content warning to append * @param reverse If true, then the additional CW will be prepended instead of appended. */ -export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string { +export function appendContentWarning(original: string | null | undefined, additional: string, reverse?: boolean): string; +export function appendContentWarning(original: string, additional: string | null | undefined, reverse?: boolean): string; +export function appendContentWarning(original: string | null | undefined, additional: string | null | undefined, reverse?: boolean): string | null; +export function appendContentWarning(original: string | null | undefined, additional: string | null | undefined, reverse = false): string | null { // Easy case - if original is empty, then additional replaces it. if (!original) { - return additional; + return additional ?? null; } // Easy case - if the additional CW is empty, then don't append it. diff --git a/packages/backend/src/misc/bigint.ts b/packages/backend/src/misc/bigint.ts new file mode 100644 index 0000000000..efa1527ec9 --- /dev/null +++ b/packages/backend/src/misc/bigint.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +function parseBigIntChunked(str: string, base: number, chunkSize: number, powerOfChunkSize: bigint): bigint { + const chunks = []; + while (str.length > 0) { + chunks.unshift(str.slice(-chunkSize)); + str = str.slice(0, -chunkSize); + } + let result = 0n; + for (const chunk of chunks) { + result *= powerOfChunkSize; + const int = parseInt(chunk, base); + if (Number.isNaN(int)) { + throw new Error('Invalid base36 string'); + } + result += BigInt(int); + } + return result; +} + +export function parseBigInt36(str: string): bigint { + // log_36(Number.MAX_SAFE_INTEGER) => 10.251599391715352 + // so we process 10 chars at once + return parseBigIntChunked(str, 36, 10, 36n ** 10n); +} + +export function parseBigInt16(str: string): bigint { + // log_16(Number.MAX_SAFE_INTEGER) => 13.25 + // so we process 13 chars at once + return parseBigIntChunked(str, 16, 13, 16n ** 13n); +} + +export function parseBigInt32(str: string): bigint { + // log_32(Number.MAX_SAFE_INTEGER) => 10.6 + // so we process 10 chars at once + return parseBigIntChunked(str, 32, 10, 32n ** 10n); +} diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f9692ce5d5..666e684c1c 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -9,9 +9,9 @@ import { bindThis } from '@/decorators.js'; export class RedisKVCache { private readonly lifetime: number; private readonly memoryCache: MemoryKVCache; - private readonly fetcher: (key: string) => Promise; - private readonly toRedisConverter: (value: T) => string; - private readonly fromRedisConverter: (value: string) => T | undefined; + public readonly fetcher: (key: string) => Promise; + public readonly toRedisConverter: (value: T) => string; + public readonly fromRedisConverter: (value: string) => T | undefined; constructor( private redisClient: Redis.Redis, @@ -19,16 +19,16 @@ export class RedisKVCache { opts: { lifetime: RedisKVCache['lifetime']; memoryCacheLifetime: number; - fetcher: RedisKVCache['fetcher']; - toRedisConverter: RedisKVCache['toRedisConverter']; - fromRedisConverter: RedisKVCache['fromRedisConverter']; + fetcher?: RedisKVCache['fetcher']; + toRedisConverter?: RedisKVCache['toRedisConverter']; + fromRedisConverter?: RedisKVCache['fromRedisConverter']; }, ) { this.lifetime = opts.lifetime; this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); - this.fetcher = opts.fetcher; - this.toRedisConverter = opts.toRedisConverter; - this.fromRedisConverter = opts.fromRedisConverter; + this.fetcher = opts.fetcher ?? (() => { throw new Error('fetch not supported - use get/set directly'); }); + this.toRedisConverter = opts.toRedisConverter ?? ((value) => JSON.stringify(value)); + this.fromRedisConverter = opts.fromRedisConverter ?? ((value) => JSON.parse(value)); } @bindThis @@ -99,6 +99,11 @@ export class RedisKVCache { // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする } + @bindThis + public clear() { + this.memoryCache.clear(); + } + @bindThis public gc() { this.memoryCache.gc(); @@ -113,9 +118,9 @@ export class RedisKVCache { export class RedisSingleCache { private readonly lifetime: number; private readonly memoryCache: MemorySingleCache; - private readonly fetcher: () => Promise; - private readonly toRedisConverter: (value: T) => string; - private readonly fromRedisConverter: (value: string) => T | undefined; + public readonly fetcher: () => Promise; + public readonly toRedisConverter: (value: T) => string; + public readonly fromRedisConverter: (value: string) => T | undefined; constructor( private redisClient: Redis.Redis, @@ -123,16 +128,17 @@ export class RedisSingleCache { opts: { lifetime: number; memoryCacheLifetime: number; - fetcher: RedisSingleCache['fetcher']; - toRedisConverter: RedisSingleCache['toRedisConverter']; - fromRedisConverter: RedisSingleCache['fromRedisConverter']; + fetcher?: RedisSingleCache['fetcher']; + toRedisConverter?: RedisSingleCache['toRedisConverter']; + fromRedisConverter?: RedisSingleCache['fromRedisConverter']; }, ) { this.lifetime = opts.lifetime; this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); - this.fetcher = opts.fetcher; - this.toRedisConverter = opts.toRedisConverter; - this.fromRedisConverter = opts.fromRedisConverter; + + this.fetcher = opts.fetcher ?? (() => { throw new Error('fetch not supported - use get/set directly'); }); + this.toRedisConverter = opts.toRedisConverter ?? ((value) => JSON.stringify(value)); + this.fromRedisConverter = opts.fromRedisConverter ?? ((value) => JSON.parse(value)); } @bindThis @@ -237,6 +243,16 @@ export class MemoryKVCache { return cached.value; } + public has(key: string): boolean { + const cached = this.cache.get(key); + if (cached == null) return false; + if ((Date.now() - cached.date) > this.lifetime) { + this.cache.delete(key); + return false; + } + return true; + } + @bindThis public delete(key: string): void { this.cache.delete(key); @@ -308,11 +324,24 @@ export class MemoryKVCache { } } + /** + * Removes all entries from the cache, but does not dispose it. + */ + @bindThis + public clear(): void { + this.cache.clear(); + } + @bindThis public dispose(): void { + this.clear(); clearInterval(this.gcIntervalHandle); } + public get size() { + return this.cache.size; + } + public get entries() { return this.cache.entries(); } diff --git a/packages/backend/src/misc/create-temp.ts b/packages/backend/src/misc/create-temp.ts index 6cc896046f..9ba95cff42 100644 --- a/packages/backend/src/misc/create-temp.ts +++ b/packages/backend/src/misc/create-temp.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { pipeline } from 'node:stream/promises'; +import fs from 'node:fs'; import * as tmp from 'tmp'; export function createTemp(): Promise<[string, () => void]> { @@ -27,3 +29,21 @@ export function createTempDir(): Promise<[string, () => void]> { ); }); } + +export async function saveToTempFile(stream: NodeJS.ReadableStream & { truncated?: boolean }): Promise<[string, () => void]> { + const [filepath, cleanup] = await createTemp(); + + try { + await pipeline(stream, fs.createWriteStream(filepath)); + } catch (e) { + cleanup(); + throw e; + } + + if (stream.truncated) { + cleanup(); + throw new Error('Read failed: input stream truncated'); + } + + return [filepath, cleanup]; +} diff --git a/packages/backend/src/misc/diff-arrays.ts b/packages/backend/src/misc/diff-arrays.ts new file mode 100644 index 0000000000..b50ca1d4f7 --- /dev/null +++ b/packages/backend/src/misc/diff-arrays.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface DiffResult { + added: T[]; + removed: T[]; +} + +/** + * Calculates the difference between two snapshots of data. + * Null, undefined, and empty arrays are supported, and duplicate values are ignored. + * Result sets are de-duplicated, and will be empty if no data was added or removed (respectively). + * The inputs are treated as un-ordered, so a re-ordering of the same data will NOT be considered a change. + * @param dataBefore Array containing data before the change + * @param dataAfter Array containing data after the change + */ +export function diffArrays(dataBefore: T[] | null | undefined, dataAfter: T[] | null | undefined): DiffResult { + const before = dataBefore ? new Set(dataBefore) : null; + const after = dataAfter ? new Set(dataAfter) : null; + + // data before AND after => changed + if (before?.size && after?.size) { + const added: T[] = []; + const removed: T[] = []; + + for (const host of before) { + // before and NOT after => removed + // delete operation removes duplicates to speed up the "after" loop + if (!after.delete(host)) { + removed.push(host); + } + } + + for (const host of after) { + // after and NOT before => added + if (!before.has(host)) { + added.push(host); + } + } + + return { added, removed }; + } + + // data ONLY before => all removed + if (before?.size) { + return { added: [], removed: Array.from(before) }; + } + + // data ONLY after => all added + if (after?.size) { + return { added: Array.from(after), removed: [] }; + } + + // data NEITHER before nor after => no change + return { added: [], removed: [] }; +} + +/** + * Checks for any difference between two snapshots of data. + * Null, undefined, and empty arrays are supported, and duplicate values are ignored. + * The inputs are treated as un-ordered, so a re-ordering of the same data will NOT be considered a change. + * @param dataBefore Array containing data before the change + * @param dataAfter Array containing data after the change + */ +export function diffArraysSimple(dataBefore: T[] | null | undefined, dataAfter: T[] | null | undefined): boolean { + const before = dataBefore ? new Set(dataBefore) : null; + const after = dataAfter ? new Set(dataAfter) : null; + + if (before?.size && after?.size) { + // different size => changed + if (before.size !== after.size) return true; + + // removed => changed + for (const host of before) { + // delete operation removes duplicates to speed up the "after" loop + if (!after.delete(host)) { + return true; + } + } + + // added => changed + for (const host of after) { + if (!before.has(host)) { + return true; + } + } + + // identical values => no change + return false; + } + + // before and NOT after => change + if (before?.size) return true; + + // after and NOT before => change + if (after?.size) return true; + + // NEITHER before nor after => no change + return false; +} 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 36a9b8e1f4..73ae9abb54 100644 --- a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts +++ b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import { unique } from '@/misc/prelude/array.js'; export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] { diff --git a/packages/backend/src/misc/extract-hashtags.ts b/packages/backend/src/misc/extract-hashtags.ts index ed7606d995..d3d245d414 100644 --- a/packages/backend/src/misc/extract-hashtags.ts +++ b/packages/backend/src/misc/extract-hashtags.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import { unique } from '@/misc/prelude/array.js'; export function extractHashtags(nodes: mfm.MfmNode[]): string[] { diff --git a/packages/backend/src/misc/extract-mentions.ts b/packages/backend/src/misc/extract-mentions.ts index bb21c32ffb..2ec9349718 100644 --- a/packages/backend/src/misc/extract-mentions.ts +++ b/packages/backend/src/misc/extract-mentions.ts @@ -5,7 +5,7 @@ // test is located in test/extract-mentions -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { // TODO: 重複を削除 diff --git a/packages/backend/src/misc/fastify-reply-error.ts b/packages/backend/src/misc/fastify-reply-error.ts index e6c4e78d2f..03109e8b96 100644 --- a/packages/backend/src/misc/fastify-reply-error.ts +++ b/packages/backend/src/misc/fastify-reply-error.ts @@ -8,8 +8,8 @@ export class FastifyReplyError extends Error { public message: string; public statusCode: number; - constructor(statusCode: number, message: string) { - super(message); + constructor(statusCode: number, message: string, cause?: unknown) { + super(message, cause ? { cause } : undefined); this.message = message; this.statusCode = statusCode; } diff --git a/packages/backend/src/misc/from-tuple.ts b/packages/backend/src/misc/from-tuple.ts index 366b1e310f..034bae584b 100644 --- a/packages/backend/src/misc/from-tuple.ts +++ b/packages/backend/src/misc/from-tuple.ts @@ -1,4 +1,11 @@ -export function fromTuple(value: T | [T]): T { +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function fromTuple(value: T | [T]): T; +export function fromTuple(value: T | [T] | T[]): T | undefined; +export function fromTuple(value: T | [T] | T[]): T | undefined { if (Array.isArray(value)) { return value[0]; } diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts index f3c08cc76e..ac7db82f2e 100644 --- a/packages/backend/src/misc/gen-identicon.ts +++ b/packages/backend/src/misc/gen-identicon.ts @@ -44,7 +44,7 @@ const sideN = Math.floor(n / 2); /** * Generate buffer of an identicon by seed */ -export async function genIdenticon(seed: string): Promise { +export function genIdenticon(seed: string): Buffer { const rand = gen.create(seed); const canvas = createCanvas(size, size); const ctx = canvas.getContext('2d'); @@ -100,5 +100,5 @@ export async function genIdenticon(seed: string): Promise { } } - return await canvas.toBuffer('image/png'); + return canvas.toBuffer('image/png'); } diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 60ba788e44..f0eba2d99c 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -7,6 +7,8 @@ // 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列] import * as crypto from 'node:crypto'; +import { parseBigInt36 } from '@/misc/bigint.js'; +import { IdentifiableError } from '../identifiable-error.js'; export const aidRegExp = /^[0-9a-z]{10}$/; @@ -25,7 +27,7 @@ function getNoise(): string { } export function genAid(t: number): string { - if (isNaN(t)) throw new Error('Failed to create AID: Invalid Date'); + if (isNaN(t)) throw new IdentifiableError('6b73b7d5-9d2b-48b4-821c-ef955efe80ad', 'Failed to create AID: Invalid Date'); counter++; return getTime(t) + getNoise(); } @@ -35,6 +37,12 @@ export function parseAid(id: string): { date: Date; } { return { date: new Date(time) }; } +export function parseAidFull(id: string): { date: number; additional: bigint; } { + const date = parseInt(id.slice(0, 8), 36) + TIME2000; + const additional = parseBigInt36(id.slice(8, 10)); + return { date, additional }; +} + 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 index 1b087e70af..d2bb566e35 100644 --- a/packages/backend/src/misc/id/aidx.ts +++ b/packages/backend/src/misc/id/aidx.ts @@ -9,6 +9,8 @@ // https://misskey.m544.net/notes/71899acdcc9859ec5708ac24 import { customAlphabet } from 'nanoid'; +import { parseBigInt36 } from '@/misc/bigint.js'; +import { IdentifiableError } from '../identifiable-error.js'; export const aidxRegExp = /^[0-9a-z]{16}$/; @@ -16,6 +18,7 @@ const TIME2000 = 946684800000; const TIME_LENGTH = 8; const NODE_LENGTH = 4; const NOISE_LENGTH = 4; +const AIDX_LENGTH = TIME_LENGTH + NODE_LENGTH + NOISE_LENGTH; const nodeId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', NODE_LENGTH)(); let counter = 0; @@ -32,7 +35,7 @@ function getNoise(): string { } export function genAidx(t: number): string { - if (isNaN(t)) throw new Error('Failed to create AIDX: Invalid Date'); + if (isNaN(t)) throw new IdentifiableError('6b73b7d5-9d2b-48b4-821c-ef955efe80ad', 'Failed to create AIDX: Invalid Date'); counter++; return getTime(t) + nodeId + getNoise(); } @@ -42,6 +45,12 @@ export function parseAidx(id: string): { date: Date; } { return { date: new Date(time) }; } +export function parseAidxFull(id: string): { date: number; additional: bigint; } { + const date = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000; + const additional = parseBigInt36(id.slice(TIME_LENGTH, AIDX_LENGTH)); + return { date, additional }; +} + 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 dfab48a369..563e07ed8f 100644 --- a/packages/backend/src/misc/id/meid.ts +++ b/packages/backend/src/misc/id/meid.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // same as object-id @@ -39,6 +41,13 @@ export function parseMeid(id: string): { date: Date; } { }; } +export function parseMeidFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(0, 12), 16) - 0x800000000000, + additional: parseBigInt16(id.slice(12, 24)), + }; +} + export function isSafeMeidT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts index b9c0cc3dda..b825807114 100644 --- a/packages/backend/src/misc/id/meidg.ts +++ b/packages/backend/src/misc/id/meidg.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // 4bit Fixed hex value 'g' @@ -39,6 +41,13 @@ export function parseMeidg(id: string): { date: Date; } { }; } +export function parseMeidgFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(1, 12), 16), + additional: parseBigInt16(id.slice(12, 24)), + }; +} + export function isSafeMeidgT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts index 243f92bbac..68409c7a61 100644 --- a/packages/backend/src/misc/id/object-id.ts +++ b/packages/backend/src/misc/id/object-id.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // same as meid @@ -39,6 +41,13 @@ export function parseObjectId(id: string): { date: Date; } { }; } +export function parseObjectIdFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(0, 8), 16) * 1000, + additional: parseBigInt16(id.slice(8, 24)), + }; +} + export function isSafeObjectIdT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/ulid.ts b/packages/backend/src/misc/id/ulid.ts index fc3654d6d2..8b81702d19 100644 --- a/packages/backend/src/misc/id/ulid.ts +++ b/packages/backend/src/misc/id/ulid.ts @@ -5,15 +5,27 @@ // Crockford's Base32 // https://github.com/ulid/spec#encoding +import { parseBigInt32 } from '@/misc/bigint.js'; + const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/; -export function parseUlid(id: string): { date: Date; } { - const timestamp = id.slice(0, 10); +function parseBase32(timestamp: string) { let time = 0; - for (let i = 0; i < 10; i++) { + for (let i = 0; i < timestamp.length; i++) { time = time * 32 + CHARS.indexOf(timestamp[i]); } - return { date: new Date(time) }; + return time; +} + +export function parseUlid(id: string): { date: Date; } { + return { date: new Date(parseBase32(id.slice(0, 10))) }; +} + +export function parseUlidFull(id: string): { date: number; additional: bigint; } { + return { + date: parseBase32(id.slice(0, 10)), + additional: parseBigInt32(id.slice(10, 26)), + }; } diff --git a/packages/backend/src/misc/identifiable-error.ts b/packages/backend/src/misc/identifiable-error.ts index f5c3fcd6cb..56e13f2622 100644 --- a/packages/backend/src/misc/identifiable-error.ts +++ b/packages/backend/src/misc/identifiable-error.ts @@ -15,8 +15,8 @@ export class IdentifiableError extends Error { */ public readonly isRetryable: boolean; - constructor(id: string, message?: string, isRetryable = false) { - super(message); + constructor(id: string, message?: string, isRetryable = false, cause?: unknown) { + super(message, cause ? { cause } : undefined); this.message = message ?? ''; this.id = id; this.isRetryable = isRetryable; 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 300c4c05b3..0000000000 --- a/packages/backend/src/misc/is-native-token.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * 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-renote.ts b/packages/backend/src/misc/is-renote.ts index d6872de46a..fcaafaf95a 100644 --- a/packages/backend/src/misc/is-renote.ts +++ b/packages/backend/src/misc/is-renote.ts @@ -77,7 +77,7 @@ type PackedPureRenote = PackedRenote & { replyId: NonNullable['replyId']>; poll: NonNullable['poll']>; fileIds: NonNullable['fileIds']>; -} +}; export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote { return note.renoteId != null; diff --git a/packages/backend/src/misc/is-retryable-error.ts b/packages/backend/src/misc/is-retryable-error.ts index 9bb8700c7a..63b561b280 100644 --- a/packages/backend/src/misc/is-retryable-error.ts +++ b/packages/backend/src/misc/is-retryable-error.ts @@ -3,20 +3,34 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { AbortError } from 'node-fetch'; +import { AbortError, FetchError } from 'node-fetch'; import { UnrecoverableError } from 'bullmq'; import { StatusError } from '@/misc/status-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { CaptchaError, captchaErrorCodes } from '@/core/CaptchaService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; +import { ConflictError } from '@/server/SkRateLimiterService.js'; /** * Returns false if the provided value represents a "permanent" error that cannot be retried. * Returns true if the error is retryable, unknown (as all errors are retryable by default), or not an error object. */ export function isRetryableError(e: unknown): boolean { + if (e instanceof AggregateError) return e.errors.every(inner => isRetryableError(inner)); if (e instanceof StatusError) return e.isRetryable; if (e instanceof IdentifiableError) return e.isRetryable; + if (e instanceof CaptchaError) { + if (e.code === captchaErrorCodes.verificationFailed) return false; + if (e.code === captchaErrorCodes.invalidParameters) return false; + if (e.code === captchaErrorCodes.invalidProvider) return false; + return true; + } + if (e instanceof FastifyReplyError) return false; + if (e instanceof ConflictError) return true; if (e instanceof UnrecoverableError) return false; if (e instanceof AbortError) return true; + if (e instanceof FetchError) return true; + if (e instanceof SyntaxError) return false; if (e instanceof Error) return e.name === 'AbortError'; return true; } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index f612591eda..27aa3d89de 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -63,6 +63,10 @@ import { } from '@/models/json-schema/meta.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; +import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessageLiteForRoomSchema, packedChatMessageLiteFor1on1Schema } from '@/models/json-schema/chat-message.js'; +import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js'; +import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js'; +import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -120,6 +124,13 @@ export const refs = { MetaDetailed: packedMetaDetailedSchema, SystemWebhook: packedSystemWebhookSchema, AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, + ChatMessage: packedChatMessageSchema, + ChatMessageLite: packedChatMessageLiteSchema, + ChatMessageLiteFor1on1: packedChatMessageLiteFor1on1Schema, + ChatMessageLiteForRoom: packedChatMessageLiteForRoomSchema, + ChatRoom: packedChatRoomSchema, + ChatRoomInvitation: packedChatRoomInvitationSchema, + ChatRoomMembership: packedChatRoomMembershipSchema, }; export type Packed = SchemaType; @@ -143,7 +154,7 @@ type OfSchema = { readonly anyOf?: ReadonlyArray; readonly oneOf?: ReadonlyArray; readonly allOf?: ReadonlyArray; -} +}; export interface Schema extends OfSchema { readonly type?: TypeStringef; @@ -166,15 +177,16 @@ export interface Schema extends OfSchema { readonly maximum?: number; readonly minimum?: number; readonly pattern?: string; + readonly additionalProperties?: Schema | boolean; } 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 + // 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; @@ -213,11 +225,18 @@ type ObjectSchemaTypeDef

= 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 + : ObjType> + : + p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md + p['allOf'] extends ReadonlyArray ? UnionToIntersection> : + p['additionalProperties'] extends true ? Record : + p['additionalProperties'] extends Schema ? + p['additionalProperties'] extends infer AdditionalProperties ? + AdditionalProperties extends Schema ? + Record> : + never : + never : + any; type ObjectSchemaType

= NullOrUndefined>; @@ -227,30 +246,30 @@ export type SchemaTypeDef

= 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['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['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['prefixItems'] extends ReadonlyArray ? ( + p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : + p['items'] extends false ? ArrayToTuple : + p['unevaluatedItems'] extends false ? ArrayToTuple : + [...ArrayToTuple, ...unknown[]] + ) : + p['items'] extends NonNullable ? SchemaType[] : + any[] ) : - p['prefixItems'] extends ReadonlyArray ? ( - p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : - p['items'] extends false ? ArrayToTuple : - p['unevaluatedItems'] extends false ? ArrayToTuple : - [...ArrayToTuple, ...unknown[]] - ) : - p['items'] extends NonNullable ? SchemaType[] : - any[] - ) : - p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : - p['oneOf'] extends ReadonlyArray ? UnionSchemaType : - any; + p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : + p['oneOf'] extends ReadonlyArray ? UnionSchemaType : + any; export type SchemaType

= NullOrUndefined>; diff --git a/packages/backend/src/misc/json-value.ts b/packages/backend/src/misc/json-value.ts index bd7fe12058..195f7c4d47 100644 --- a/packages/backend/src/misc/json-value.ts +++ b/packages/backend/src/misc/json-value.ts @@ -4,7 +4,7 @@ */ export type JsonValue = JsonArray | JsonObject | string | number | boolean | null; -export type JsonObject = {[K in string]?: JsonValue}; +export type JsonObject = { [K in string]?: JsonValue }; export type JsonArray = JsonValue[]; export function isJsonObject(value: JsonValue | undefined): value is JsonObject { diff --git a/packages/backend/src/misc/promise-tracker.ts b/packages/backend/src/misc/promise-tracker.ts index 8a52ca703e..76b4dd810c 100644 --- a/packages/backend/src/misc/promise-tracker.ts +++ b/packages/backend/src/misc/promise-tracker.ts @@ -5,6 +5,10 @@ const promiseRefs: Set>> = new Set(); +export function trackTask(task: () => Promise): void { + trackPromise(task()); +} + /** * This tracks promises that other modules decided not to wait for, * and makes sure they are all settled before fully closing down the server. diff --git a/packages/backend/src/misc/render-full-error.ts b/packages/backend/src/misc/render-full-error.ts new file mode 100644 index 0000000000..5f0a09bba9 --- /dev/null +++ b/packages/backend/src/misc/render-full-error.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Bull from 'bullmq'; +import { AbortError, FetchError } from 'node-fetch'; +import { StatusError } from '@/misc/status-error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +import { CaptchaError, captchaErrorCodes } from '@/core/CaptchaService.js'; + +export function renderFullError(e?: unknown): unknown { + if (e === undefined) return 'undefined'; + if (e === null) return 'null'; + + if (e instanceof Error) { + if (isSimpleError(e)) { + return renderInlineError(e); + } + + const data: ErrorData = {}; + if (e.stack) data.stack = e.stack; + if (e.message) data.message = e.message; + if (e.name) data.name = e.name; + + // mix "cause" and "errors" + if (e instanceof AggregateError && e.errors.length > 0) { + const causes = e.errors.map(inner => renderFullError(inner)); + if (e.cause) { + causes.push(renderFullError(e.cause)); + } + data.cause = causes; + } else if (e.cause) { + data.cause = renderFullError(e.cause); + } + + return data; + } + + return e; +} + +function isSimpleError(e: Error): boolean { + if (e instanceof Bull.UnrecoverableError) return true; + if (e instanceof AbortError || e.name === 'AbortError') return true; + if (e instanceof FetchError || e.name === 'FetchError') return true; + if (e instanceof StatusError) return true; + if (e instanceof IdentifiableError) return true; + if (e instanceof FetchError) return true; + if (e instanceof CaptchaError && e.code !== captchaErrorCodes.unknown) return true; + return false; +} + +interface ErrorData { + stack?: Error['stack']; + message?: Error['message']; + name?: Error['name']; + cause?: Error['cause'] | Error['cause'][]; +} diff --git a/packages/backend/src/misc/render-inline-error.ts b/packages/backend/src/misc/render-inline-error.ts new file mode 100644 index 0000000000..07f9f3068e --- /dev/null +++ b/packages/backend/src/misc/render-inline-error.ts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { StatusError } from '@/misc/status-error.js'; +import { CaptchaError } from '@/core/CaptchaService.js'; + +export function renderInlineError(err: unknown): string { + const parts: string[] = []; + renderTo(err, parts); + return parts.join(''); +} + +function renderTo(err: unknown, parts: string[]): void { + parts.push(printError(err)); + + if (err instanceof AggregateError) { + for (let i = 0; i < err.errors.length; i++) { + parts.push(` [${i + 1}/${err.errors.length}]: `); + renderTo(err.errors[i], parts); + } + } + + if (err instanceof Error) { + if (err.cause) { + parts.push(' [caused by]: '); + renderTo(err.cause, parts); + // const cause = renderInlineError(err.cause); + // parts.push(' [caused by]: ', cause); + } + } +} + +function printError(err: unknown): string { + if (err === undefined) return 'undefined'; + if (err === null) return 'null'; + + if (err instanceof IdentifiableError) { + if (err.message) { + return `${err.name} ${err.id}: ${err.message}`; + } else { + return `${err.name} ${err.id}`; + } + } + + if (err instanceof StatusError) { + if (err.message) { + return `${err.name} ${err.statusCode}: ${err.message}`; + } else if (err.statusMessage) { + return `${err.name} ${err.statusCode}: ${err.statusMessage}`; + } else { + return `${err.name} ${err.statusCode}`; + } + } + + if (err instanceof CaptchaError) { + if (err.code.description) { + return `${err.name} ${err.code.description}: ${err.message}`; + } else { + return `${err.name}: ${err.message}`; + } + } + + if (err instanceof Error) { + if (err.message) { + return `${err.name}: ${err.message}`; + } else { + return err.name; + } + } + + return String(err); +} diff --git a/packages/backend/src/misc/status-error.ts b/packages/backend/src/misc/status-error.ts index c3533db607..4fd3bfcafb 100644 --- a/packages/backend/src/misc/status-error.ts +++ b/packages/backend/src/misc/status-error.ts @@ -9,8 +9,8 @@ export class StatusError extends Error { public isClientError: boolean; public isRetryable: boolean; - constructor(message: string, statusCode: number, statusMessage?: string) { - super(message); + constructor(message: string, statusCode: number, statusMessage?: string, cause?: unknown) { + super(message, cause ? { cause } : undefined); this.name = 'StatusError'; this.statusCode = statusCode; this.statusMessage = statusMessage; diff --git a/packages/backend/src/misc/generate-native-user-token.ts b/packages/backend/src/misc/token.ts similarity index 54% rename from packages/backend/src/misc/generate-native-user-token.ts rename to packages/backend/src/misc/token.ts index 85fb383ba2..5d37cba26d 100644 --- a/packages/backend/src/misc/generate-native-user-token.ts +++ b/packages/backend/src/misc/token.ts @@ -5,5 +5,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js'; -// eslint-disable-next-line import/no-default-export -export default () => secureRndstr(16); +export const generateNativeUserToken = () => secureRndstr(16); + +export const isNativeUserToken = (token: string) => token.length === 16; diff --git a/packages/backend/src/misc/truncate.ts b/packages/backend/src/misc/truncate.ts index 1c8a274609..a313ab7854 100644 --- a/packages/backend/src/misc/truncate.ts +++ b/packages/backend/src/misc/truncate.ts @@ -3,14 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { substring } from 'stringz'; - export function truncate(input: string, size: number): string; export function truncate(input: string | undefined, size: number): string | undefined; export function truncate(input: string | undefined, size: number): string | undefined { if (!input) { return input; } else { - return substring(input, 0, size); + return input.slice(0, size); } } diff --git a/packages/backend/src/misc/verify-field-link.ts b/packages/backend/src/misc/verify-field-link.ts new file mode 100644 index 0000000000..f9fc352806 --- /dev/null +++ b/packages/backend/src/misc/verify-field-link.ts @@ -0,0 +1,31 @@ +/* +* SPDX-FileCopyrightText: piuvas and other Sharkey contributors +* SPDX-License-Identifier: AGPL-3.0-only +*/ + +import { load as cheerio } from 'cheerio/slim'; +import type { HttpRequestService } from '@/core/HttpRequestService.js'; + +type Field = { name: string, value: string }; + +export async function verifyFieldLinks(fields: Field[], profile_url: string, httpRequestService: HttpRequestService): Promise { + const verified_links = []; + for (const field_url of fields.filter(x => URL.canParse(x.value) && ['http:', 'https:'].includes((new URL(x.value).protocol)))) { + try { + const html = await httpRequestService.getHtml(field_url.value); + + const doc = cheerio(html); + + const links = doc('a[rel~="me"][href], link[rel~="me"][href]').toArray(); + + const includesProfileLinks = links.some(link => link.attribs.href === profile_url); + if (includesProfileLinks) { + verified_links.push(field_url.value); + } + } catch { + // don't do anything. + } + } + + return verified_links; +} diff --git a/packages/backend/src/models/AbuseReportNotificationRecipient.ts b/packages/backend/src/models/AbuseReportNotificationRecipient.ts index fbff880afc..fd31354a80 100644 --- a/packages/backend/src/models/AbuseReportNotificationRecipient.ts +++ b/packages/backend/src/models/AbuseReportNotificationRecipient.ts @@ -22,7 +22,7 @@ export class MiAbuseReportNotificationRecipient { /** * 有効かどうか. */ - @Index() + @Index('IDX_abuse_report_notification_recipient_isActive') @Column('boolean', { default: true, }) @@ -47,7 +47,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知方法. */ - @Index() + @Index('IDX_abuse_report_notification_recipient_method') @Column('varchar', { length: 64, }) @@ -56,7 +56,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザID. */ - @Index() + @Index('IDX_abuse_report_notification_recipient_userId') @Column({ ...id(), nullable: true, @@ -75,14 +75,16 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザプロフィール. */ - @ManyToOne(type => MiUserProfile, {}) + @ManyToOne(type => MiUserProfile, { + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' }) public userProfile: MiUserProfile | null; /** * 通知先のシステムWebhookId. */ - @Index() + @Index('IDX_abuse_report_notification_recipient_systemWebhookId') @Column({ ...id(), nullable: true, @@ -95,6 +97,8 @@ export class MiAbuseReportNotificationRecipient { @ManyToOne(type => MiSystemWebhook, { onDelete: 'CASCADE', }) - @JoinColumn() + @JoinColumn({ + foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_systemWebhookId', + }) public systemWebhook: MiSystemWebhook | null; } diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts index d43ebf9342..8f8d759004 100644 --- a/packages/backend/src/models/AbuseUserReport.ts +++ b/packages/backend/src/models/AbuseUserReport.ts @@ -4,6 +4,7 @@ */ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -88,11 +89,31 @@ export class MiAbuseUserReport { }) public targetUserHost: string | null; + @ManyToOne(() => MiInstance, { + // TODO create a foreign key constraint after hazelnoot/labs/persisted-instance-blocks is merged + createForeignKeyConstraints: false, + }) + @JoinColumn({ + name: 'targetUserHost', + referencedColumnName: 'host', + }) + public targetUserInstance: MiInstance | null; + @Index() @Column('varchar', { length: 128, nullable: true, comment: '[Denormalized]', }) public reporterHost: string | null; + + @ManyToOne(() => MiInstance, { + // TODO create a foreign key constraint after hazelnoot/labs/persisted-instance-blocks is merged + createForeignKeyConstraints: false, + }) + @JoinColumn({ + name: 'reporterHost', + referencedColumnName: 'host', + }) + public reporterInstance: MiInstance | null; //#endregion } diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index 33e6f48189..17ec0c0f79 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -100,4 +100,9 @@ export class MiAntenna { default: false, }) public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public excludeNotesInSensitiveChannel: boolean; } diff --git a/packages/backend/src/models/ChatApproval.ts b/packages/backend/src/models/ChatApproval.ts new file mode 100644 index 0000000000..55c9f07e9a --- /dev/null +++ b/packages/backend/src/models/ChatApproval.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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('chat_approval') +@Index(['userId', 'otherId'], { unique: true }) +export class MiChatApproval { + @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 otherId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public other: MiUser | null; +} diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts new file mode 100644 index 0000000000..3d2b64268e --- /dev/null +++ b/packages/backend/src/models/ChatMessage.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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'; +import { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_message') +export class MiChatMessage { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public fromUserId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public fromUser: MiUser | null; + + @Index() + @Column({ + ...id(), nullable: true, + }) + public toUserId: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public toUser: MiUser | null; + + @Index() + @Column({ + ...id(), nullable: true, + }) + public toRoomId: MiChatRoom['id'] | null; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public toRoom: MiChatRoom | null; + + @Column('varchar', { + length: 4096, nullable: true, + }) + public text: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public uri: string | null; + + @Column({ + ...id(), + array: true, default: '{}', + }) + public reads: MiUser['id'][]; + + @Column({ + ...id(), + nullable: true, + }) + public fileId: MiDriveFile['id'] | null; + + @ManyToOne(type => MiDriveFile, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public file: MiDriveFile | null; + + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public reactions: string[]; +} diff --git a/packages/backend/src/models/ChatRoom.ts b/packages/backend/src/models/ChatRoom.ts new file mode 100644 index 0000000000..ad2a910b78 --- /dev/null +++ b/packages/backend/src/models/ChatRoom.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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('chat_room') +export class MiChatRoom { + @PrimaryColumn(id()) + public id: string; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Index() + @Column({ + ...id(), + }) + public ownerId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public owner: MiUser | null; + + @Column('varchar', { + length: 2048, default: '', + }) + public description: string; + + @Column('boolean', { + default: false, + }) + public isArchived: boolean; +} diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts new file mode 100644 index 0000000000..36ce12bc92 --- /dev/null +++ b/packages/backend/src/models/ChatRoomInvitation.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_room_invitation') +@Index(['userId', 'roomId'], { unique: true }) +export class MiChatRoomInvitation { + @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 roomId: MiChatRoom['id']; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public room: MiChatRoom | null; + + @Column('boolean', { + default: false, + }) + public ignored: boolean; +} diff --git a/packages/backend/src/models/ChatRoomMembership.ts b/packages/backend/src/models/ChatRoomMembership.ts new file mode 100644 index 0000000000..3cb5524859 --- /dev/null +++ b/packages/backend/src/models/ChatRoomMembership.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_room_membership') +@Index(['userId', 'roomId'], { unique: true }) +export class MiChatRoomMembership { + @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 roomId: MiChatRoom['id']; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public room: MiChatRoom | null; + + @Column('boolean', { + default: false, + }) + public isMuted: boolean; +} diff --git a/packages/backend/src/models/Emoji.ts b/packages/backend/src/models/Emoji.ts index 9ef570deb1..70800681c4 100644 --- a/packages/backend/src/models/Emoji.ts +++ b/packages/backend/src/models/Emoji.ts @@ -29,6 +29,7 @@ export class MiEmoji { }) public host: string | null; + @Index('IDX_EMOJI_CATEGORY') @Column('varchar', { length: 128, nullable: true, }) @@ -77,6 +78,8 @@ export class MiEmoji { public isSensitive: boolean; // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする + // Synchronize: false is needed because TypeORM doesn't understand GIN indexes + @Index('IDX_EMOJI_ROLE_IDS', { synchronize: false }) @Column('varchar', { array: true, length: 128, default: '{}', }) diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 62cbc29f26..0aa1b13976 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -4,6 +4,7 @@ */ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -66,6 +67,16 @@ export class MiFollowing { }) public followerHost: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'followerHost', + foreignKeyConstraintName: 'FK_following_followerHost', + referencedColumnName: 'host', + }) + public followerInstance: MiInstance | null; + @Column('varchar', { length: 512, nullable: true, comment: '[Denormalized]', @@ -85,6 +96,16 @@ export class MiFollowing { }) public followeeHost: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'followeeHost', + foreignKeyConstraintName: 'FK_following_followeeHost', + referencedColumnName: 'host', + }) + public followeeInstance: MiInstance | null; + @Column('varchar', { length: 512, nullable: true, comment: '[Denormalized]', diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index c64ebb1b3b..0cde4b75fc 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -6,6 +6,7 @@ import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; import { id } from './util/id.js'; +@Index('IDX_instance_host_key', { synchronize: false }) // ((lower(reverse("host"::text)) || '.'::text) @Entity('instance') export class MiInstance { @PrimaryColumn(id()) @@ -98,6 +99,56 @@ export class MiInstance { }) public suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'; + /** + * True if this instance is blocked from federation. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is blocked from federation.', + }) + public isBlocked: boolean; + + /** + * True if this instance is allow-listed. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is allow-listed.', + }) + public isAllowListed: boolean; + + /** + * True if this instance is part of the local bubble. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is part of the local bubble.', + }) + public isBubbled: boolean; + + /** + * True if this instance is silenced. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is silenced.', + }) + public isSilenced: boolean; + + /** + * True if this instance is media-silenced. + */ + @Column('boolean', { + nullable: false, + default: false, + comment: 'True if this instance is media-silenced.', + }) + public isMediaSilenced: boolean; + @Column('varchar', { length: 64, nullable: true, comment: 'The software of the Instance.', diff --git a/packages/backend/src/models/LatestNote.ts b/packages/backend/src/models/LatestNote.ts index 064fcccc0a..37efb0d4b6 100644 --- a/packages/backend/src/models/LatestNote.ts +++ b/packages/backend/src/models/LatestNote.ts @@ -45,6 +45,7 @@ export class SkLatestNote { }) @JoinColumn({ name: 'user_id', + foreignKeyConstraintName: 'FK_20e346fffe4a2174585005d6d80', }) public user: MiUser | null; @@ -60,6 +61,7 @@ export class SkLatestNote { }) @JoinColumn({ name: 'note_id', + foreignKeyConstraintName: 'FK_47a38b1c13de6ce4e5090fb1acd', }) public note: MiNote | null; diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index ba7128c49c..01ca56af30 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Entity, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { type InstanceUnsignedFetchOption, instanceUnsignedFetchOptions } from '@/const.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -15,6 +16,18 @@ export class MiMeta { }) public id: string; + @Column({ + ...id(), + nullable: true, + }) + public rootUserId: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'SET NULL', + nullable: true, + }) + public rootUser: MiUser | null; + @Column('varchar', { length: 1024, nullable: true, }) @@ -178,18 +191,6 @@ export class MiMeta { }) 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, }) @@ -381,6 +382,12 @@ export class MiMeta { }) public swPrivateKey: string | null; + @Column('integer', { + default: 5000, + comment: 'Timeout in milliseconds for translation API requests', + }) + public translationTimeout: number; + @Column('varchar', { length: 1024, nullable: true, @@ -403,6 +410,18 @@ export class MiMeta { }) public deeplFreeInstance: string | null; + @Column('varchar', { + length: 1024, + nullable: true, + }) + public libreTranslateURL: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public libreTranslateKey: string | null; + @Column('varchar', { length: 1024, nullable: true, @@ -412,7 +431,7 @@ export class MiMeta { @Column('varchar', { length: 1024, default: 'https://activitypub.software/TransFem-org/Sharkey/', - nullable: true, // this is indeed supposed to be nullable. accidentally set wrong in e248e41287 + nullable: true, }) public repositoryUrl: string | null; @@ -599,8 +618,8 @@ export class MiMeta { }) public enableAchievements: boolean; - @Column('varchar', { - length: 2048, nullable: true, + @Column('text', { + nullable: true, }) public robotsTxt: string | null; @@ -630,7 +649,7 @@ export class MiMeta { 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" }', + 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[]; @@ -681,10 +700,9 @@ export class MiMeta { // this is a mess lol @Column('varchar', { length: 500, - nullable: true, - default: '❤️' + default: '❤️', }) - public defaultLike: string | null; + public defaultLike: string; @Column('varchar', { length: 256, array: true, default: '{}', @@ -743,4 +761,18 @@ export class MiMeta { default: '{}', }) public federationHosts: string[]; + + /** + * In combination with user.allowUnsignedFetch, controls enforcement of HTTP signatures for inbound ActivityPub fetches (GET requests). + */ + @Column('enum', { + enum: instanceUnsignedFetchOptions, + default: 'always', + }) + public allowUnsignedFetch: InstanceUnsignedFetchOption; + + @Column('boolean', { + default: false, + }) + public enableProxyAccount: boolean; } diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 2dabb75d83..bbe183cfbb 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -5,11 +5,15 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; import { noteVisibilities } from '@/types.js'; +import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiChannel } from './Channel.js'; import type { MiDriveFile } from './DriveFile.js'; +@Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id']) +@Index('IDX_note_userHost_id', { synchronize: false }) // (userHost, id desc) +@Index('IDX_note_for_timelines', { synchronize: false }) // (id desc, channelId, visibility, userHost) @Entity('note') export class MiNote { @PrimaryColumn(id()) @@ -71,7 +75,6 @@ export class MiNote { }) public cw: string | null; - @Index() @Column({ ...id(), comment: 'The ID of author.', @@ -130,6 +133,7 @@ export class MiNote { }) public uri: string | null; + @Index('IDX_note_url') @Column('varchar', { length: 512, nullable: true, comment: 'The human readable url of a note. it will be null when the note is local.', @@ -215,13 +219,22 @@ export class MiNote { public processErrors: string[] | null; //#region Denormalized fields - @Index() @Column('varchar', { length: 128, nullable: true, comment: '[Denormalized]', }) public userHost: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'userHost', + foreignKeyConstraintName: 'FK_note_userHost', + referencedColumnName: 'host', + }) + public userInstance: MiInstance | null; + @Column({ ...id(), nullable: true, @@ -235,6 +248,16 @@ export class MiNote { }) public replyUserHost: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'replyUserHost', + foreignKeyConstraintName: 'FK_note_replyUserHost', + referencedColumnName: 'host', + }) + public replyUserInstance: MiInstance | null; + @Column({ ...id(), nullable: true, @@ -247,6 +270,16 @@ export class MiNote { comment: '[Denormalized]', }) public renoteUserHost: string | null; + + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'renoteUserHost', + foreignKeyConstraintName: 'FK_note_renoteUserHost', + referencedColumnName: 'host', + }) + public renoteUserInstance: MiInstance | null; //#endregion constructor(data: Partial) { @@ -264,3 +297,7 @@ export type IMentionedRemoteUsers = { username: string; host: string; }[]; + +export function hasText(note: MiNote): note is MiNote & { text: string } { + return note.text != null; +} diff --git a/packages/backend/src/models/NoteEdit.ts b/packages/backend/src/models/NoteEdit.ts index 26ea91b670..449c974d52 100644 --- a/packages/backend/src/models/NoteEdit.ts +++ b/packages/backend/src/models/NoteEdit.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, JoinColumn, Column, ManyToOne, PrimaryColumn, Index } from "typeorm"; +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'; @@ -16,27 +16,22 @@ export class NoteEdit { @Index() @Column({ ...id(), - comment: "The ID of note.", + comment: 'The ID of note.', }) - public noteId: MiNote["id"]; + public noteId: MiNote['id']; - @ManyToOne((type) => MiNote, { - onDelete: "CASCADE", + @ManyToOne(() => MiNote, { + onDelete: 'CASCADE', }) @JoinColumn() public note: MiNote | null; - @Column("text", { - nullable: true, - }) - public oldText: string | null; - - @Column("text", { + @Column('text', { nullable: true, }) public newText: string | null; - @Column("varchar", { + @Column('varchar', { length: 512, nullable: true, }) @@ -45,17 +40,22 @@ export class NoteEdit { @Column({ ...id(), array: true, - default: "{}", + default: '{}', }) - public fileIds: MiDriveFile["id"][]; + public fileIds: MiDriveFile['id'][]; - @Column("timestamp with time zone", { - comment: "The updated date of the Note.", + @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", + @Column('text', { + nullable: true, + }) + public oldText: string | null; + + @Column('timestamp with time zone', { + comment: 'The old date from before the edit', nullable: true, }) public oldDate: Date | null; diff --git a/packages/backend/src/models/NoteSchedule.ts b/packages/backend/src/models/NoteSchedule.ts index dde0af6ad7..c9d031c281 100644 --- a/packages/backend/src/models/NoteSchedule.ts +++ b/packages/backend/src/models/NoteSchedule.ts @@ -17,7 +17,7 @@ type MinimumUser = { uri: MiUser['uri']; }; -export type MiScheduleNoteType={ +export type MiScheduleNoteType = { visibility: 'public' | 'home' | 'followers' | 'specified'; visibleUsers: MinimumUser[]; channel?: MiChannel['id']; @@ -37,7 +37,7 @@ export type MiScheduleNoteType={ apMentions?: MinimumUser[] | null; apHashtags?: string[] | null; apEmojis?: string[] | null; -} +}; @Entity('note_schedule') export class MiNoteSchedule { diff --git a/packages/backend/src/models/NoteUnread.ts b/packages/backend/src/models/NoteUnread.ts deleted file mode 100644 index c759181117..0000000000 --- a/packages/backend/src/models/NoteUnread.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * 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 index 6d7a453879..cff95cbf6e 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -75,6 +75,12 @@ export type MiNotification = { id: string; createdAt: string; roleId: MiRole['id']; +} | { + type: 'chatRoomInvitationReceived'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + invitationId: string; } | { type: 'achievementEarned'; id: string; @@ -90,6 +96,10 @@ export type MiNotification = { type: 'login'; id: string; createdAt: string; +} | { + type: 'createToken'; + id: string; + createdAt: string; } | { type: 'app'; id: string; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 78510ba588..5e0154fe50 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Provider } from '@nestjs/common'; import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import { @@ -45,7 +44,6 @@ import { MiNoteReaction, MiNoteSchedule, MiNoteThreadMuting, - MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, @@ -65,6 +63,7 @@ import { MiRoleAssignment, MiSignin, MiSwSubscription, + MiSystemAccount, MiSystemWebhook, MiUsedUsername, MiUser, @@ -80,11 +79,17 @@ import { MiUserPublickey, MiUserSecurityKey, MiWebhook, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, NoteEdit, SkApContext, SkApFetchLog, SkApInboxLog, } from './_.js'; +import type { Provider } from '@nestjs/common'; import type { DataSource } from 'typeorm'; const $usersRepository: Provider = { @@ -165,12 +170,6 @@ const $noteReactionsRepository: Provider = { inject: [DI.db], }; -const $noteUnreadsRepository: Provider = { - provide: DI.noteUnreadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteUnread).extend(miRepository as MiRepository), - inject: [DI.db], -}; - const $pollsRepository: Provider = { provide: DI.pollsRepository, useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository), @@ -315,6 +314,12 @@ const $swSubscriptionsRepository: Provider = { inject: [DI.db], }; +const $systemAccountsRepository: Provider = { + provide: DI.systemAccountsRepository, + useFactory: (db: DataSource) => db.getRepository(MiSystemAccount).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $hashtagsRepository: Provider = { provide: DI.hashtagsRepository, useFactory: (db: DataSource) => db.getRepository(MiHashtag).extend(miRepository as MiRepository), @@ -329,7 +334,7 @@ const $abuseUserReportsRepository: Provider = { const $abuseReportNotificationRecipientRepository: Provider = { provide: DI.abuseReportNotificationRecipientRepository, - useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient), + useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient).extend(miRepository as MiRepository), inject: [DI.db], }; @@ -461,7 +466,7 @@ const $webhooksRepository: Provider = { const $systemWebhooksRepository: Provider = { provide: DI.systemWebhooksRepository, - useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook), + useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook).extend(miRepository as MiRepository), inject: [DI.db], }; @@ -519,6 +524,36 @@ const $noteEditRepository: Provider = { inject: [DI.db], }; +const $chatMessagesRepository: Provider = { + provide: DI.chatMessagesRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatMessage).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomsRepository: Provider = { + provide: DI.chatRoomsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoom).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomMembershipsRepository: Provider = { + provide: DI.chatRoomMembershipsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoomMembership).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomInvitationsRepository: Provider = { + provide: DI.chatRoomInvitationsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoomInvitation).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatApprovalsRepository: Provider = { + provide: DI.chatApprovalsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatApproval).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $bubbleGameRecordsRepository: Provider = { provide: DI.bubbleGameRecordsRepository, useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord).extend(miRepository as MiRepository), @@ -553,7 +588,6 @@ const $noteScheduleRepository: Provider = { $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, - $noteUnreadsRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -578,6 +612,7 @@ const $noteScheduleRepository: Provider = { $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, + $systemAccountsRepository, $hashtagsRepository, $abuseUserReportsRepository, $abuseReportNotificationRecipientRepository, @@ -611,6 +646,11 @@ const $noteScheduleRepository: Provider = { $flashsRepository, $flashLikesRepository, $userMemosRepository, + $chatMessagesRepository, + $chatRoomsRepository, + $chatRoomMembershipsRepository, + $chatRoomInvitationsRepository, + $chatApprovalsRepository, $noteEditRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, @@ -630,7 +670,6 @@ const $noteScheduleRepository: Provider = { $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, - $noteUnreadsRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -655,6 +694,7 @@ const $noteScheduleRepository: Provider = { $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, + $systemAccountsRepository, $hashtagsRepository, $abuseUserReportsRepository, $abuseReportNotificationRecipientRepository, @@ -688,6 +728,11 @@ const $noteScheduleRepository: Provider = { $flashsRepository, $flashLikesRepository, $userMemosRepository, + $chatMessagesRepository, + $chatRoomsRepository, + $chatRoomMembershipsRepository, + $chatRoomInvitationsRepository, + $chatApprovalsRepository, $noteEditRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index a173971b2c..f6e3050830 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -47,6 +47,22 @@ type CondFormulaValueIsRemote = { type: 'isRemote'; }; +/** + * User is from a specific instance + */ +type CondFormulaValueIsFromInstance = { + type: 'isFromInstance'; + host: string; + subdomains: boolean; +}; + +/** + * Is the user from a local bubble instance + */ +type CondFormulaValueFromBubbleInstance = { + type: 'fromBubbleInstance'; +}; + /** * 既に指定のマニュアルロールにアサインされている場合のみ成立とする */ @@ -138,6 +154,70 @@ type CondFormulaValueFollowingMoreThanOrEq = { value: number; }; +/** + * Is followed by at most N local users + */ +type CondFormulaValueLocalFollowersLessThanOrEq = { + type: 'localFollowersLessThanOrEq'; + value: number; +}; + +/** + * Is followed by at least N local users + */ +type CondFormulaValueLocalFollowersMoreThanOrEq = { + type: 'localFollowersMoreThanOrEq'; + value: number; +}; + +/** + * Is following at most N local users + */ +type CondFormulaValueLocalFollowingLessThanOrEq = { + type: 'localFollowingLessThanOrEq'; + value: number; +}; + +/** + * Is following at least N local users + */ +type CondFormulaValueLocalFollowingMoreThanOrEq = { + type: 'localFollowingMoreThanOrEq'; + value: number; +}; + +/** + * Is followed by at most N remote users + */ +type CondFormulaValueRemoteFollowersLessThanOrEq = { + type: 'remoteFollowersLessThanOrEq'; + value: number; +}; + +/** + * Is followed by at least N remote users + */ +type CondFormulaValueRemoteFollowersMoreThanOrEq = { + type: 'remoteFollowersMoreThanOrEq'; + value: number; +}; + +/** + * Is following at most N remote users + */ +type CondFormulaValueRemoteFollowingLessThanOrEq = { + type: 'remoteFollowingLessThanOrEq'; + value: number; +}; + +/** + * Is following at least N remote users + */ +type CondFormulaValueRemoteFollowingMoreThanOrEq = { + type: 'remoteFollowingMoreThanOrEq'; + value: number; +}; + /** * 投稿数が指定値以下の場合のみ成立とする */ @@ -160,6 +240,8 @@ export type RoleCondFormulaValue = { id: string } & ( CondFormulaValueNot | CondFormulaValueIsLocal | CondFormulaValueIsRemote | + CondFormulaValueIsFromInstance | + CondFormulaValueFromBubbleInstance | CondFormulaValueIsSuspended | CondFormulaValueIsLocked | CondFormulaValueIsBot | @@ -172,6 +254,14 @@ export type RoleCondFormulaValue = { id: string } & ( CondFormulaValueFollowersMoreThanOrEq | CondFormulaValueFollowingLessThanOrEq | CondFormulaValueFollowingMoreThanOrEq | + CondFormulaValueLocalFollowersLessThanOrEq | + CondFormulaValueLocalFollowersMoreThanOrEq | + CondFormulaValueLocalFollowingLessThanOrEq | + CondFormulaValueLocalFollowingMoreThanOrEq | + CondFormulaValueRemoteFollowersLessThanOrEq | + CondFormulaValueRemoteFollowersMoreThanOrEq | + CondFormulaValueRemoteFollowingLessThanOrEq | + CondFormulaValueRemoteFollowingMoreThanOrEq | CondFormulaValueNotesLessThanOrEq | CondFormulaValueNotesMoreThanOrEq ); @@ -248,6 +338,11 @@ export class MiRole { }) public isExplorable: boolean; + @Column('boolean', { + default: false, + }) + public preserveAssignmentOnMoveAccount: boolean; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/SystemAccount.ts b/packages/backend/src/models/SystemAccount.ts new file mode 100644 index 0000000000..f32880b81d --- /dev/null +++ b/packages/backend/src/models/SystemAccount.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Serialized } from '@/types.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('system_account') +@Index(['type'], { unique: true }) +export class MiSystemAccount { + @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: 256, + }) + public type: string; +} diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 4fbcd5a176..1f42209bfe 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn, ManyToOne } from 'typeorm'; +import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.js'; +import { MiInstance } from '@/models/Instance.js'; import { id } from './util/id.js'; import { MiDriveFile } from './DriveFile.js'; +import type { MiUserProfile } from './UserProfile.js'; @Entity('user') @Index(['usernameLower', 'host'], { unique: true }) @@ -125,17 +128,21 @@ export class MiUser { }) public backgroundId: MiDriveFile['id'] | null; - @OneToOne(type => MiDriveFile, { + @OneToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) - @JoinColumn() + @JoinColumn({ + foreignKeyConstraintName: 'FK_q5lm0tbgejtfskzg0rc4wd7t1n', + }) public background: MiDriveFile | null; + // avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること @Column('varchar', { length: 512, nullable: true, }) public avatarUrl: string | null; + // bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること @Column('varchar', { length: 512, nullable: true, }) @@ -146,11 +153,13 @@ export class MiUser { }) public backgroundUrl: string | null; + // avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること @Column('varchar', { length: 128, nullable: true, }) public avatarBlurhash: string | null; + // bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること @Column('varchar', { length: 128, nullable: true, }) @@ -226,12 +235,6 @@ export class MiUser { }) public speakAsCat: boolean; - @Column('boolean', { - default: false, - comment: 'Whether the User is the root.', - }) - public isRoot: boolean; - @Index() @Column('boolean', { default: true, @@ -273,6 +276,17 @@ export class MiUser { }) public emojis: string[]; + // チャットを許可する相手 + // everyone: 誰からでも + // followers: フォロワーのみ + // following: フォローしているユーザーのみ + // mutual: 相互フォローのみ + // none: 誰からも受け付けない + @Column('varchar', { + length: 128, default: 'mutual', + }) + public chatScope: 'everyone' | 'followers' | 'following' | 'mutual' | 'none'; + @Index() @Column('varchar', { length: 128, nullable: true, @@ -280,6 +294,16 @@ export class MiUser { }) public host: string | null; + @ManyToOne(() => MiInstance, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'host', + foreignKeyConstraintName: 'FK_user_host', + referencedColumnName: 'host', + }) + public instance: MiInstance | 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.', @@ -334,7 +358,7 @@ export class MiUser { */ @Column('boolean', { name: 'enable_rss', - default: true, + default: false, }) public enableRss: boolean; @@ -356,6 +380,24 @@ export class MiUser { }) public rejectQuotes: boolean; + /** + * In combination with meta.allowUnsignedFetch, controls enforcement of HTTP signatures for inbound ActivityPub fetches (GET requests). + */ + @Column('enum', { + enum: userUnsignedFetchOptions, + default: 'staff', + }) + public allowUnsignedFetch: UserUnsignedFetchOption; + + @Column('text', { + name: 'attributionDomains', + array: true, default: '{}', + }) + public attributionDomains: string[]; + + @OneToOne('user_profile', (profile: MiUserProfile) => profile.user) + public userProfile: MiUserProfile | null; + constructor(data: Partial) { if (data == null) return; @@ -368,24 +410,24 @@ export class MiUser { 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; @@ -393,5 +435,5 @@ export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as con export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const; export const followedMessageSchema = { type: 'string', minLength: 1, maxLength: 256 } 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 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/UserListMembership.ts b/packages/backend/src/models/UserListMembership.ts index af659d071d..99ec8bdc26 100644 --- a/packages/backend/src/models/UserListMembership.ts +++ b/packages/backend/src/models/UserListMembership.ts @@ -24,7 +24,9 @@ export class MiUserListMembership { @ManyToOne(type => MiUser, { onDelete: 'CASCADE', }) - @JoinColumn() + @JoinColumn({ + foreignKeyConstraintName: 'FK_d844bfc6f3f523a05189076efaa', + }) public user: MiUser | null; @Index() @@ -37,7 +39,9 @@ export class MiUserListMembership { @ManyToOne(type => MiUserList, { onDelete: 'CASCADE', }) - @JoinColumn() + @JoinColumn({ + foreignKeyConstraintName: 'FK_605472305f26818cc93d1baaa74', + }) public userList: MiUserList | null; // タイムラインにその人のリプライまで含めるかどうか diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 1ad12e5fd7..4f1762874f 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -4,7 +4,7 @@ */ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes, noteVisibilities, defaultCWPriorities } from '@/types.js'; +import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes, defaultCWPriorities } from '@/types.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; @@ -17,7 +17,7 @@ export class MiUserProfile { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, user => user.userProfile, { onDelete: 'CASCADE', }) @JoinColumn() @@ -110,12 +110,14 @@ export class MiUserProfile { @Column('enum', { enum: followingVisibilities, + enumName: 'user_profile_followingVisibility_enum', default: 'private', }) public followingVisibility: typeof followingVisibilities[number]; @Column('enum', { enum: followersVisibilities, + enumName: 'user_profile_followersVisibility_enum', default: 'private', }) public followersVisibility: typeof followersVisibilities[number]; diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 4bd6e78ef4..225e8ac025 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,33 +3,48 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm'; -import { DriverUtils } from 'typeorm/driver/DriverUtils.js'; +import { + FindOneOptions, + InsertQueryBuilder, + ObjectLiteral, + QueryRunner, + Repository, + SelectQueryBuilder, +} from 'typeorm'; +import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; -import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; -import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; -import { OrmUtils } from 'typeorm/util/OrmUtils.js'; -import { SkLatestNote } from '@/models/LatestNote.js'; -import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import { + RawSqlResultsToEntityTransformer, +} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.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 { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiBlocking } from '@/models/Blocking.js'; -import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; +import { MiChannel } from '@/models/Channel.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; +import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiChatApproval } from '@/models/ChatApproval.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; +import { MiChatRoom } from '@/models/ChatRoom.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; import { MiClip } from '@/models/Clip.js'; -import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; +import { MiClipNote } from '@/models/ClipNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiFlash } from '@/models/Flash.js'; +import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -39,12 +54,11 @@ 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 { MiNoteSchedule } from '@/models/NoteSchedule.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'; @@ -55,41 +69,43 @@ 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 { MiRenoteMuting } from '@/models/RenoteMuting.js'; +import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; +import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiRole } from '@/models/Role.js'; +import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiSignin } from '@/models/Signin.js'; import { MiSwSubscription } from '@/models/SwSubscription.js'; +import { MiSystemAccount } from '@/models/SystemAccount.js'; +import { MiSystemWebhook } from '@/models/SystemWebhook.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 { MiUserMemo } from '@/models/UserMemo.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 { MiSystemWebhook } from '@/models/SystemWebhook.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 type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; import { NoteEdit } from '@/models/NoteEdit.js'; -import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; -import { MiReversiGame } from '@/models/ReversiGame.js'; -import { MiNoteSchedule } from '@/models/NoteSchedule.js'; import { SkApInboxLog } from '@/models/SkApInboxLog.js'; import { SkApFetchLog } from '@/models/SkApFetchLog.js'; import { SkApContext } from '@/models/SkApContext.js'; -import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; export interface MiRepository { createTableColumnNames(this: Repository & MiRepository): string[]; + insertOne(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>): Promise; + + insertOneImpl(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>, queryRunner?: QueryRunner): Promise; + selectAliasColumnNames(this: Repository & MiRepository, queryBuilder: InsertQueryBuilder, builder: SelectQueryBuilder): void; } @@ -98,6 +114,21 @@ export const miRepository = { return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName); }, async insertOne(entity, findOptions?) { + const opt = this.manager.connection.options as PostgresConnectionOptions; + if (opt.replication) { + const queryRunner = this.manager.connection.createQueryRunner('master'); + try { + return this.insertOneImpl(entity, findOptions, queryRunner); + } finally { + await queryRunner.release(); + } + } else { + return this.insertOneImpl(entity, findOptions); + } + }, + async insertOneImpl(entity, findOptions?, queryRunner?) { + // ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ---- + const queryBuilder = this.createQueryBuilder().insert().values(entity); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const mainAlias = queryBuilder.expressionMap.mainAlias!; @@ -105,7 +136,9 @@ export const miRepository = { mainAlias.name = 't'; const columnNames = this.createTableColumnNames(); queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2)); - const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames }); + + // ---- 共通テーブル式(CTE)から結果を取得 ---- + const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion builder.expressionMap.mainAlias!.tablePath = 'cte'; this.selectAliasColumnNames(queryBuilder, builder); @@ -169,7 +202,6 @@ export { MiNoteReaction, MiNoteSchedule, MiNoteThreadMuting, - MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, @@ -182,6 +214,7 @@ export { MiRelay, MiSignin, MiSwSubscription, + MiSystemAccount, MiUsedUsername, MiUser, MiUserIp, @@ -203,13 +236,20 @@ export { MiFlash, MiFlashLike, MiUserMemo, - NoteEdit, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, MiBubbleGameRecord, MiReversiGame, + NoteEdit, }; export type AbuseUserReportsRepository = Repository & MiRepository; -export type AbuseReportNotificationRecipientRepository = Repository & MiRepository; +export type AbuseReportNotificationRecipientRepository = + Repository + & MiRepository; export type AccessTokensRepository = Repository & MiRepository; export type AdsRepository = Repository & MiRepository; export type AnnouncementsRepository = Repository & MiRepository; @@ -245,7 +285,6 @@ export type NotesRepository = Repository & MiRepository; export type NoteFavoritesRepository = Repository & MiRepository; export type NoteReactionsRepository = Repository & MiRepository; export type NoteThreadMutingsRepository = Repository & MiRepository; -export type NoteUnreadsRepository = Repository & MiRepository; export type PagesRepository = Repository & MiRepository; export type PageLikesRepository = Repository & MiRepository; export type PasswordResetRequestsRepository = Repository & MiRepository; @@ -258,6 +297,7 @@ export type RegistryItemsRepository = Repository & MiRepository< export type RelaysRepository = Repository & MiRepository; export type SigninsRepository = Repository & MiRepository; export type SwSubscriptionsRepository = Repository & MiRepository; +export type SystemAccountsRepository = Repository & MiRepository; export type UsedUsernamesRepository = Repository & MiRepository; export type UsersRepository = Repository & MiRepository; export type UserIpsRepository = Repository & MiRepository; @@ -279,6 +319,11 @@ export type RoleAssignmentsRepository = Repository & MiReposit export type FlashsRepository = Repository & MiRepository; export type FlashLikesRepository = Repository & MiRepository; export type UserMemoRepository = Repository & MiRepository; +export type ChatMessagesRepository = Repository & MiRepository; +export type ChatRoomsRepository = Repository & MiRepository; +export type ChatRoomMembershipsRepository = Repository & MiRepository; +export type ChatRoomInvitationsRepository = Repository & MiRepository; +export type ChatApprovalsRepository = Repository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; export type NoteEditRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index b5b9a5b42c..eca7563066 100644 --- a/packages/backend/src/models/json-schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -100,5 +100,10 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, + excludeNotesInSensitiveChannel: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/chat-message.ts b/packages/backend/src/models/json-schema/chat-message.ts new file mode 100644 index 0000000000..3b5e85ab69 --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-message.ts @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatMessageSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + toUserId: { + type: 'string', + optional: true, nullable: true, + }, + toUser: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + toRoomId: { + type: 'string', + optional: true, nullable: true, + }, + toRoom: { + type: 'object', + optional: true, nullable: true, + ref: 'ChatRoom', + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + isRead: { + type: 'boolean', + optional: true, nullable: false, + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; + +export const packedChatMessageLiteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + toUserId: { + type: 'string', + optional: true, nullable: true, + }, + toRoomId: { + type: 'string', + optional: true, nullable: true, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; + +export const packedChatMessageLiteFor1on1Schema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + toUserId: { + type: 'string', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + }, +} as const; + +export const packedChatMessageLiteForRoomSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + toRoomId: { + type: 'string', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room-invitation.ts b/packages/backend/src/models/json-schema/chat-room-invitation.ts new file mode 100644 index 0000000000..204c959b2c --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room-invitation.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomInvitationSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + roomId: { + type: 'string', + optional: false, nullable: false, + }, + room: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room-membership.ts b/packages/backend/src/models/json-schema/chat-room-membership.ts new file mode 100644 index 0000000000..adb73f9dde --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room-membership.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomMembershipSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + roomId: { + type: 'string', + optional: false, nullable: false, + }, + room: { + type: 'object', + optional: true, nullable: false, + ref: 'ChatRoom', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room.ts b/packages/backend/src/models/json-schema/chat-room.ts new file mode 100644 index 0000000000..e97556e378 --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + ownerId: { + type: 'string', + optional: false, nullable: false, + }, + owner: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + isMuted: { + type: 'boolean', + optional: true, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 57d4466ffa..fd6eddf594 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -135,5 +135,9 @@ export const packedFederationInstanceSchema = { type: 'string', optional: true, nullable: true, }, + isBubbled: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index bf68208c37..8cc1686ac6 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { instanceUnsignedFetchOptions } from '@/const.js'; + export const packedMetaLiteSchema = { type: 'object', optional: false, nullable: false, @@ -259,6 +261,38 @@ export const packedMetaLiteSchema = { type: 'boolean', optional: false, nullable: false, }, + sentryForFrontend: { + type: 'object', + optional: false, nullable: true, + properties: { + options: { + type: 'object', + optional: false, nullable: false, + properties: { + dsn: { + type: 'string', + optional: false, nullable: false, + }, + }, + additionalProperties: true, + }, + vueIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + browserTracingIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + replayIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + }, + }, mediaProxy: { type: 'string', optional: false, nullable: false, @@ -397,6 +431,11 @@ export const packedMetaDetailedOnlySchema = { type: 'boolean', optional: false, nullable: false, }, + allowUnsignedFetch: { + type: 'string', + enum: instanceUnsignedFetchOptions, + optional: false, nullable: false, + }, }, } 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 ba936f866b..0000000000 --- a/packages/backend/src/models/json-schema/note-edit.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -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.ts b/packages/backend/src/models/json-schema/note.ts index 16e240ab11..b19c8f7c06 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -85,6 +85,16 @@ export const packedNoteSchema = { format: 'id', }, }, + mentionHandles: { + type: 'object', + optional: true, nullable: false, + additionalProperties: { + anyOf: [{ + type: 'string', + }], + optional: true, nullable: false, + }, + }, visibleUserIds: { type: 'array', optional: true, nullable: false, diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 248234a674..e7f8359556 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -287,6 +287,21 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['chatRoomInvitationReceived'], + }, + invitation: { + type: 'object', + ref: 'ChatRoomInvitation', + optional: false, nullable: false, + }, + }, }, { type: 'object', properties: { @@ -332,6 +347,16 @@ export const packedNotificationSchema = { enum: ['login'], }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['createToken'], + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index ef0bb9f141..363be921ed 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -228,6 +228,10 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, + maxFileSizeMb: { + type: 'integer', + optional: false, nullable: false, + }, alwaysMarkNsfw: { type: 'boolean', optional: false, nullable: false, @@ -300,6 +304,15 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, + chatAvailability: { + type: 'string', + optional: false, nullable: false, + enum: ['available', 'readonly', 'unavailable'], + }, + canTrend: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; @@ -393,6 +406,11 @@ export const packedRoleSchema = { optional: false, nullable: false, example: false, }, + preserveAssignmentOnMoveAccount: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, canEditMembersByModerator: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 0f1601f138..1678cab067 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { userUnsignedFetchOptions } from '@/const.js'; + export const notificationRecieveConfig = { type: 'object', oneOf: [ @@ -71,6 +73,16 @@ export const packedUserLiteSchema = { type: 'string', nullable: true, optional: false, }, + description: { + type: 'string', + nullable: true, optional: false, + example: 'Hi masters, I am Ai!', + }, + createdAt: { + type: 'string', + nullable: false, optional: false, + format: 'date-time', + }, avatarDecorations: { type: 'array', nullable: false, optional: false, @@ -198,6 +210,10 @@ export const packedUserLiteSchema = { type: 'string', nullable: true, optional: false, }, + isSilenced: { + type: 'boolean', + nullable: false, optional: false, + }, }, }, emojis: { @@ -234,6 +250,14 @@ export const packedUserLiteSchema = { }, }, }, + attributionDomains: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, }, } as const; @@ -264,11 +288,6 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: false, }, }, - createdAt: { - type: 'string', - nullable: false, optional: false, - format: 'date-time', - }, updatedAt: { type: 'string', nullable: true, optional: false, @@ -310,11 +329,6 @@ export const packedUserDetailedNotMeOnlySchema = { 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, @@ -416,6 +430,15 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: false, enum: ['public', 'followers', 'private'], }, + chatScope: { + type: 'string', + nullable: false, optional: false, + enum: ['everyone', 'following', 'followers', 'mutual', 'none'], + }, + canChat: { + type: 'boolean', + nullable: false, optional: false, + }, roles: { type: 'array', nullable: false, optional: false, @@ -607,6 +630,10 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, + hasUnreadChatMessages: { + type: 'boolean', + nullable: false, optional: false, + }, hasUnreadNotification: { type: 'boolean', nullable: false, optional: false, @@ -670,6 +697,7 @@ export const packedMeDetailedOnlySchema = { receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig }, + chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig }, achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, @@ -769,6 +797,11 @@ export const packedMeDetailedOnlySchema = { enum: ['default', 'parent', 'defaultParent', 'parentDefault'], nullable: false, optional: false, }, + allowUnsignedFetch: { + type: 'string', + enum: userUnsignedFetchOptions, + nullable: false, optional: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 1a5fdc8412..45caec54ce 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -5,9 +5,12 @@ // https://github.com/typeorm/typeorm/issues/2400 import pg from 'pg'; -import { DataSource, Logger } from 'typeorm'; +import { DataSource, Logger, type QueryRunner } from 'typeorm'; import * as highlight from 'cli-highlight'; import { entities as charts } from '@/core/chart/entities.js'; +import { Config } from '@/config.js'; +import MisskeyLogger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; @@ -42,7 +45,6 @@ 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'; @@ -76,14 +78,16 @@ 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 { MiChatMessage } from '@/models/ChatMessage.js'; +import { MiChatRoom } from '@/models/ChatRoom.js'; +import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiChatApproval } from '@/models/ChatApproval.js'; +import { MiSystemAccount } from '@/models/SystemAccount.js'; +import { NoteEdit } from '@/models/NoteEdit.js'; import { MiNoteSchedule } from '@/models/NoteSchedule.js'; - -import { Config } from '@/config.js'; -import MisskeyLogger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; import { SkLatestNote } from '@/models/LatestNote.js'; import { SkApContext } from '@/models/SkApContext.js'; import { SkApFetchLog } from '@/models/SkApFetchLog.js'; @@ -94,11 +98,15 @@ pg.types.setTypeParser(20, Number); export const dbLogger = new MisskeyLogger('db'); const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); +const sqlMigrateLogger = sqlLogger.createSubLogger('migrate'); +const sqlSchemaLogger = sqlLogger.createSubLogger('schema'); export type LoggerProps = { disableQueryTruncation?: boolean; + enableQueryLogging?: boolean; enableQueryParamLogging?: boolean; -} + printReplicationMode?: boolean, +}; function highlightSql(sql: string) { return highlight.highlight(sql, { @@ -107,7 +115,7 @@ function highlightSql(sql: string) { } function truncateSql(sql: string) { - return sql.length > 100 ? `${sql.substring(0, 100)}...` : sql; + return sql.length > 100 ? `${sql.substring(0, 100)} [truncated]` : sql; } function stringifyParameter(param: any) { @@ -123,52 +131,80 @@ class MyCustomLogger implements Logger { } @bindThis - private transformQueryLog(sql: string) { - let modded = sql; + private transformQueryLog(sql: string, opts?: { + prefix?: string; + }) { + let modded = opts?.prefix ? opts.prefix + sql : sql; if (!this.props.disableQueryTruncation) { modded = truncateSql(modded); } - return highlightSql(modded); + return this.props.enableQueryLogging ? highlightSql(modded) : modded; } @bindThis private transformParameters(parameters?: any[]) { if (this.props.enableQueryParamLogging && parameters && parameters.length > 0) { - return parameters.map(stringifyParameter); + return parameters.reduce((params, p, i) => { + params[`$${i + 1}`] = stringifyParameter(p); + return params; + }, {} as Record); } return undefined; } @bindThis - public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { + if (!this.props.enableQueryLogging) return; + + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + const transformed = this.transformQueryLog(query, { prefix }); + sqlLogger.debug(`Query run: ${transformed}`, this.transformParameters(parameters)); } @bindThis - public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + const transformed = this.transformQueryLog(query, { prefix }); + sqlLogger.error(`Query error (${error}): ${transformed}`, this.transformParameters(parameters)); } @bindThis - public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + const transformed = this.transformQueryLog(query, { prefix }); + sqlLogger.warn(`Query is slow (${time}ms): ${transformed}`, this.transformParameters(parameters)); } @bindThis public logSchemaBuild(message: string) { - sqlLogger.info(message); + sqlSchemaLogger.debug(message); } @bindThis - public log(message: string) { - sqlLogger.info(message); + public log(level: 'log' | 'info' | 'warn', message: string) { + switch (level) { + case 'log': + case 'info': { + sqlLogger.info(message); + break; + } + case 'warn': { + sqlLogger.warn(message); + } + } } @bindThis public logMigration(message: string) { - sqlLogger.info(message); + sqlMigrateLogger.debug(message); } } @@ -205,7 +241,6 @@ export const entities = [ MiNoteReaction, MiNoteSchedule, MiNoteThreadMuting, - MiNoteUnread, MiPage, MiPageLike, MiGalleryPost, @@ -217,6 +252,7 @@ export const entities = [ MiEmoji, MiHashtag, MiSwSubscription, + MiSystemAccount, MiAbuseUserReport, MiAbuseReportNotificationRecipient, MiRegistrationTicket, @@ -245,9 +281,14 @@ export const entities = [ MiFlash, MiFlashLike, MiUserMemo, - NoteEdit, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, MiBubbleGameRecord, MiReversiGame, + NoteEdit, ...charts, ]; @@ -285,7 +326,7 @@ export function createPostgresDataSource(config: Config) { } : {}), synchronize: process.env.NODE_ENV === 'test', dropSchema: process.env.NODE_ENV === 'test', - cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) + cache: config.db.disableCache === false && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) type: 'ioredis', options: { ...config.redis, @@ -293,13 +334,13 @@ export function createPostgresDataSource(config: Config) { }, } : false, logging: log, - logger: log - ? new MyCustomLogger({ - disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, - enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, - }) - : undefined, - maxQueryExecutionTime: 300, + logger: new MyCustomLogger({ + disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, + enableQueryLogging: log, + enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, + printReplicationMode: !!config.dbReplications, + }), + maxQueryExecutionTime: config.db.slowQueryThreshold, entities: entities, migrations: ['../../migration/*.js'], }); diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 297edfd545..4c1a6a1d9e 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -11,7 +11,7 @@ import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; -import { StatusError } from '@/misc/status-error.js'; +import { renderFullError } from '@/misc/render-full-error.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -47,7 +47,7 @@ import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; -import { QUEUE, baseQueueOptions } from './const.js'; +import { QUEUE, baseWorkerOptions } from './const.js'; import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js'; // ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 @@ -73,7 +73,9 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string { const currentAttempts = job.attemptsMade + (increment ? 1 : 0); const maxAttempts = job.opts.attempts ?? 0; - return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; + return job.name + ? `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated} name=${job.name}` + : `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; } @Injectable() @@ -134,35 +136,6 @@ export class QueueProcessorService implements OnApplicationShutdown { ) { this.logger = this.queueLoggerService.logger; - function renderError(e?: Error) { - // 何故かeがundefinedで来ることがある - if (!e) return '?'; - - if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError' || e instanceof StatusError) { - return `${e.name}: ${e.message}`; - } - - return { - stack: e.stack, - message: e.message, - name: e.name, - }; - } - - function renderJob(job?: Bull.Job) { - if (!job) return '?'; - - const info: Record = { - info: getJobInfo(job), - data: job.data, - }; - - if (job.name) info.name = job.name; - if (job.failedReason) info.failedReason = job.failedReason; - - return info; - } - //#region system { const processer = (job: Bull.Job) => { @@ -186,7 +159,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM), + ...baseWorkerOptions(this.config, QUEUE.SYSTEM), autorun: false, }); @@ -196,7 +169,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); + this.logError(logger, err, job); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', @@ -204,7 +177,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) + .on('error', (err: Error) => this.logError(logger, err)) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -251,7 +224,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.DB), + ...baseWorkerOptions(this.config, QUEUE.DB), autorun: false, }); @@ -261,7 +234,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); + this.logError(logger, err, job); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', @@ -269,7 +242,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) + .on('error', (err: Error) => this.logError(logger, err)) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -283,7 +256,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.deliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.DELIVER), + ...baseWorkerOptions(this.config, QUEUE.DELIVER), autorun: false, concurrency: this.config.deliverJobConcurrency ?? 128, limiter: { @@ -301,7 +274,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + this.logError(logger, err, job); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { level: 'error', @@ -309,7 +282,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) + .on('error', (err: Error) => this.logError(logger, err)) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -323,7 +296,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.inboxProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.INBOX), + ...baseWorkerOptions(this.config, QUEUE.INBOX), autorun: false, concurrency: this.config.inboxJobConcurrency ?? 16, limiter: { @@ -341,7 +314,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); + this.logError(logger, err, job); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { level: 'error', @@ -349,7 +322,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => logger.error('inbox error:', renderError(err))) + .on('error', (err: Error) => this.logError(logger, err)) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -363,7 +336,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.userWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), + ...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, concurrency: 64, limiter: { @@ -381,7 +354,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + this.logError(logger, err, job); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', @@ -389,7 +362,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) + .on('error', (err: Error) => this.logError(logger, err)) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -403,7 +376,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.systemWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), + ...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), autorun: false, concurrency: 16, limiter: { @@ -421,7 +394,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + this.logError(logger, err, job); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', @@ -429,7 +402,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) + .on('error', (err: Error) => this.logError(logger, err)) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -453,7 +426,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP), + ...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP), autorun: false, concurrency: this.config.relationshipJobConcurrency ?? 16, limiter: { @@ -468,7 +441,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); + this.logError(logger, err, job); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', @@ -476,7 +449,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) + .on('error', (err: Error) => this.logError(logger, err)) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -498,7 +471,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE), + ...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE), autorun: false, concurrency: 16, }); @@ -509,7 +482,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); + this.logError(logger, err, job); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', @@ -517,13 +490,15 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) + .on('error', (err: Error) => this.logError(logger, err)) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion //#region ended poll notification { + const logger = this.logger.createSubLogger('endedPollNotification'); + this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { if (this.config.sentryForBackend) { return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job)); @@ -531,22 +506,78 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.endedPollNotificationProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), + ...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), autorun: false, }); + this.endedPollNotificationQueueWorker + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => { + this.logError(logger, err, job); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: EndedPollNotification: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => this.logError(logger, err)) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion //#region schedule note post { + const logger = this.logger.createSubLogger('scheduleNotePost'); + this.schedulerNotePostQueueWorker = new Bull.Worker(QUEUE.SCHEDULE_NOTE_POST, (job) => this.scheduleNotePostProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.SCHEDULE_NOTE_POST), + ...baseWorkerOptions(this.config, QUEUE.SCHEDULE_NOTE_POST), autorun: false, }); + this.schedulerNotePostQueueWorker + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => { + this.logError(logger, err, job); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: ${QUEUE.SCHEDULE_NOTE_POST}: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => this.logError(logger, err)) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion } + private logError(logger: Logger, err: unknown, job?: Bull.Job | null): void { + const parts: string[] = []; + + // Render job + if (job) { + parts.push('job ['); + parts.push(getJobInfo(job)); + parts.push('] failed: '); + } else { + parts.push('job failed: '); + } + + // Render error + const fullError = renderFullError(err); + const errorText = typeof(fullError) === 'string' ? fullError : undefined; + if (errorText) { + parts.push(errorText); + } else if (job?.failedReason) { + parts.push(job.failedReason); + } + + const message = parts.join(''); + const data = typeof(fullError) !== 'string' ? { err: fullError } : undefined; + logger.error(message, data); + } + @bindThis public async start(): Promise { await Promise.all([ diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index fdf012f149..17c6b81736 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MetricsTime } from 'bullmq'; import { Config } from '@/config.js'; import type * as Bull from 'bullmq'; @@ -28,3 +29,12 @@ export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof t prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`, }; } + +export function baseWorkerOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.WorkerOptions { + return { + ...baseQueueOptions(config, queueName), + metrics: { + maxDataPoints: MetricsTime.ONE_WEEK, + }, + }; +} diff --git a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts index 4769cccabf..30bdd6ccca 100644 --- a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts +++ b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts @@ -62,7 +62,7 @@ export class AggregateRetentionProcessorService { }); } catch (err) { if (isDuplicateKeyValueError(err)) { - this.logger.succ('Skip because it has already been processed by another worker.'); + this.logger.debug('Skip because it has already been processed by another worker.'); return; } throw err; @@ -87,6 +87,6 @@ export class AggregateRetentionProcessorService { }); } - this.logger.succ('Retention aggregated.'); + this.logger.info('Retention aggregated.'); } } diff --git a/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts index d49c99f694..83b375de3f 100644 --- a/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts +++ b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts @@ -37,6 +37,6 @@ export class BakeBufferedReactionsProcessorService { await this.reactionsBufferingService.bake(); - this.logger.succ('All buffered reactions baked.'); + this.logger.info('All buffered reactions baked.'); } } diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts index 448fc9c763..76d0cb4304 100644 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -41,6 +41,6 @@ export class CheckExpiredMutingsProcessorService { await this.userMutingService.unmute(expired); } - this.logger.succ('All expired mutings checked.'); + this.logger.info('All expired mutings checked.'); } } diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts index ef21b6142e..7821cd3d1d 100644 --- a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -29,7 +29,7 @@ export type ModeratorInactivityEvaluationResult = { isModeratorsInactive: boolean; inactiveModerators: MiUser[]; remainingTime: ModeratorInactivityRemainingTime; -} +}; export type ModeratorInactivityRemainingTime = { time: number; @@ -98,16 +98,16 @@ export class CheckModeratorsActivityProcessorService { @bindThis public async process(): Promise { - this.logger.info('start.'); + this.logger.debug('start.'); const meta = await this.metaService.fetch(false); if (!meta.disableRegistration) { await this.processImpl(); } else { - this.logger.info('is already invitation only.'); + this.logger.debug('is already invitation only.'); } - this.logger.succ('finish.'); + this.logger.debug('finish.'); } @bindThis diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts index 8c5faa8d07..c11682b0fe 100644 --- a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts @@ -62,6 +62,6 @@ export class CleanChartsProcessorService { await this.perUserDriveChart.clean(); await this.apRequestChart.clean(); - this.logger.succ('All charts successfully cleaned.'); + this.logger.info('All charts successfully cleaned.'); } } diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index a26b69cd2b..104d19103f 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -69,6 +69,6 @@ export class CleanProcessorService { this.reversiService.cleanOutdatedGames(); - this.logger.succ('Cleaned.'); + this.logger.info('Cleaned.'); } } diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts index 81842b221f..2eddae95c8 100644 --- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts @@ -75,6 +75,6 @@ export class CleanRemoteFilesProcessorService { await job.updateProgress(100 / total * deletedCount); } - this.logger.succ(`All cached remote files processed. Total deleted: ${deletedCount}, Failed: ${errorCount}.`); + this.logger.info(`All cached remote files processed. Total deleted: ${deletedCount}, Failed: ${errorCount}.`); } } diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 0c70829132..5bf64e4f04 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -4,9 +4,9 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; +import { In, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule } from '@/models/_.js'; +import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository, RegistryItemsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -17,10 +17,11 @@ import { bindThis } from '@/decorators.js'; import { SearchService } from '@/core/SearchService.js'; import { ApLogService } from '@/core/ApLogService.js'; import { ReactionService } from '@/core/ReactionService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { CacheService } from '@/core/CacheService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; -import { QueueService } from '@/core/QueueService.js'; @Injectable() export class DeleteAccountProcessorService { @@ -45,6 +46,48 @@ export class DeleteAccountProcessorService { @Inject(DI.noteScheduleRepository) private noteScheduleRepository: NoteScheduleRepository, + @Inject(DI.followingsRepository) + private readonly followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private readonly followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private readonly blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private readonly mutingsRepository: MutingsRepository, + + @Inject(DI.clipsRepository) + private readonly clipsRepository: ClipsRepository, + + @Inject(DI.clipNotesRepository) + private readonly clipNotesRepository: ClipNotesRepository, + + @Inject(DI.latestNotesRepository) + private readonly latestNotesRepository: LatestNotesRepository, + + @Inject(DI.noteEditRepository) + private readonly noteEditRepository: NoteEditRepository, + + @Inject(DI.noteFavoritesRepository) + private readonly noteFavoritesRepository: NoteFavoritesRepository, + + @Inject(DI.pollVotesRepository) + private readonly pollVotesRepository: PollVotesRepository, + + @Inject(DI.pollsRepository) + private readonly pollsRepository: PollsRepository, + + @Inject(DI.signinsRepository) + private readonly signinsRepository: SigninsRepository, + + @Inject(DI.userIpsRepository) + private readonly userIpsRepository: UserIpsRepository, + + @Inject(DI.registryItemsRepository) + private readonly registryItemsRepository: RegistryItemsRepository, + private queueService: QueueService, private driveService: DriveService, private emailService: EmailService, @@ -52,6 +95,7 @@ export class DeleteAccountProcessorService { private searchService: SearchService, private reactionService: ReactionService, private readonly apLogService: ApLogService, + private readonly cacheService: CacheService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); } @@ -65,6 +109,156 @@ export class DeleteAccountProcessorService { return; } + { // Delete user clips + const userClips = await this.clipsRepository.find({ + select: { + id: true, + }, + where: { + userId: user.id, + }, + }) as { id: string }[]; + + // Delete one-at-a-time because there can be a lot + for (const clip of userClips) { + await this.clipNotesRepository.delete({ + id: clip.id, + }); + } + + await this.clipsRepository.delete({ + userId: user.id, + }); + + this.logger.info('All clips have been deleted.'); + } + + { // Delete favorites + await this.noteFavoritesRepository.delete({ + userId: user.id, + }); + + this.logger.info('All favorites have been deleted.'); + } + + { // Delete user relations + await this.cacheService.refreshFollowRelationsFor(user.id); + await this.cacheService.userFollowingsCache.delete(user.id); + await this.cacheService.userFollowingsCache.delete(user.id); + await this.cacheService.userBlockingCache.delete(user.id); + await this.cacheService.userBlockedCache.delete(user.id); + await this.cacheService.userMutingsCache.delete(user.id); + await this.cacheService.userMutingsCache.delete(user.id); + await this.cacheService.hibernatedUserCache.delete(user.id); + await this.cacheService.renoteMutingsCache.delete(user.id); + await this.cacheService.userProfileCache.delete(user.id); + this.cacheService.userByIdCache.delete(user.id); + this.cacheService.localUserByIdCache.delete(user.id); + if (user.token) { + this.cacheService.localUserByNativeTokenCache.delete(user.token); + } + + await this.followingsRepository.delete({ + followerId: user.id, + }); + + await this.followingsRepository.delete({ + followeeId: user.id, + }); + + await this.followRequestsRepository.delete({ + followerId: user.id, + }); + + await this.followRequestsRepository.delete({ + followeeId: user.id, + }); + + await this.blockingsRepository.delete({ + blockerId: user.id, + }); + + await this.blockingsRepository.delete({ + blockeeId: user.id, + }); + + await this.mutingsRepository.delete({ + muterId: user.id, + }); + + await this.mutingsRepository.delete({ + muteeId: user.id, + }); + + this.logger.info('All user relations have been 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, + }, + relations: { + note: true, + }, + }) as MiNoteReaction[]; + + if (reactions.length === 0) { + break; + } + + cursor = reactions.at(-1)?.id ?? null; + + for (const reaction of reactions) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const note = reaction.note!; + await this.reactionService.delete(user, note, reaction); + } + } + + this.logger.info('All reactions have been deleted'); + } + + { // Poll votes + let cursor: MiNoteReaction['id'] | null = null; + + while (true) { + const votes = await this.pollVotesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + select: { + id: true, + }, + take: 100, + order: { + id: 1, + }, + }) as { id: string }[]; + + if (votes.length === 0) { + break; + } + + cursor = votes.at(-1)?.id ?? null; + + await this.pollVotesRepository.delete({ + id: In(votes.map(v => v.id)), + }); + } + + this.logger.info('All poll votes have been deleted'); + } + { // Delete scheduled notes const scheduledNotes = await this.noteScheduleRepository.findBy({ userId: user.id, @@ -78,10 +272,14 @@ export class DeleteAccountProcessorService { userId: user.id, }); - this.logger.succ('All scheduled notes deleted'); + this.logger.info('All scheduled notes deleted'); } { // Delete notes + await this.latestNotesRepository.delete({ + userId: user.id, + }); + let cursor: MiNote['id'] | null = null; while (true) { @@ -102,7 +300,23 @@ export class DeleteAccountProcessorService { cursor = notes.at(-1)?.id ?? null; - await this.notesRepository.delete(notes.map(note => note.id)); + // Delete associated polls one-at-a-time, since it can cascade to a LOT of vote entries + for (const note of notes) { + if (note.hasPoll) { + await this.pollsRepository.delete({ + noteId: note.id, + }); + } + } + + const ids = notes.map(note => note.id); + + await this.noteEditRepository.delete({ + noteId: In(ids), + }); + await this.notesRepository.delete({ + id: In(ids), + }); for (const note of notes) { await this.searchService.unindexNote(note); @@ -116,38 +330,7 @@ export class DeleteAccountProcessorService { } } - 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'); + this.logger.info('All of notes deleted'); } { // Delete files @@ -176,7 +359,7 @@ export class DeleteAccountProcessorService { } } - this.logger.succ('All of files deleted'); + this.logger.info('All of files deleted'); } { // Delete actor logs @@ -184,24 +367,51 @@ export class DeleteAccountProcessorService { await this.apLogService.deleteObjectLogs(user.uri) .catch(err => this.logger.error(err, `Failed to delete AP logs for user '${user.uri}'`)); } + + await this.apLogService.deleteInboxLogs(user.id) + .catch(err => this.logger.error(err, `Failed to delete AP logs for user '${user.uri}'`)); + + this.logger.info('All AP logs deleted'); + } + + // Do this BEFORE deleting the account! + const profile = await this.userProfilesRepository.findOneBy({ userId: user.id }); + + { // Delete the actual account + await this.userIpsRepository.delete({ + userId: user.id, + }); + + await this.signinsRepository.delete({ + userId: user.id, + }); + + await this.registryItemsRepository.delete({ + userId: user.id, + }); + + // soft指定されている場合は物理削除しない + if (job.data.soft) { + // nop + } else { + await this.usersRepository.delete(user.id); + } + + this.logger.info('Account data 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.'); + if (profile && profile.email && profile.emailVerified) { + try { + await this.emailService.sendEmail(profile.email, 'Account deleted', + 'Your account has been deleted.', + 'Your account has been deleted.'); + } catch (e) { + this.logger.warn('Failed to send account deletion message:', { e }); + } } } - // 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 index 291fa4a6d8..ac3cddbed0 100644 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -74,6 +74,6 @@ export class DeleteDriveFilesProcessorService { job.updateProgress(deletedCount / total); } - this.logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); + this.logger.info(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); } } diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 5a16496011..fc4c8bb814 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -133,23 +133,18 @@ export class DeliverProcessorService { } }); - if (res instanceof StatusError) { + if (res instanceof StatusError && !res.isRetryable) { // 4xx - if (!res.isRetryable) { - // 相手が閉鎖していることを明示しているため、配送停止する - if (job.data.isSharedInbox && res.statusCode === 410) { - this.federatedInstanceService.fetchOrRegister(host).then(i => { - this.federatedInstanceService.update(i.id, { - suspensionState: 'goneSuspended', - }); + // 相手が閉鎖していることを明示しているため、配送停止する + if (job.data.isSharedInbox && res.statusCode === 410) { + this.federatedInstanceService.fetchOrRegister(host).then(i => { + this.federatedInstanceService.update(i.id, { + suspensionState: 'goneSuspended', }); - throw new Bull.UnrecoverableError(`${host} is gone`); - } - throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); + }); + throw new Bull.UnrecoverableError(`${host} is gone`); } - - // 5xx etc. - throw new Error(`${res.statusCode} ${res.statusMessage}`); + throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); } else { // DNS error, socket error, timeout ... throw res; diff --git a/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts b/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts index 33a2362c4a..58d542635f 100644 --- a/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts @@ -22,6 +22,7 @@ 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 { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -85,21 +86,23 @@ export class ExportAccountDataProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } const profile = await this.userProfilesRepository.findOneBy({ userId: job.data.user.id }); if (profile == null) { + this.logger.debug(`Skip: user ${job.data.user.id} has no profile`); return; } + this.logger.info(`Exporting account data for ${job.data.user.id} ...`); + const [path, cleanup] = await createTempDir(); - this.logger.info(`Temp dir is ${path}`); + this.logger.debug(`Temp dir is ${path}`); // User Export @@ -113,7 +116,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { userStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing user:', err); rej(err); } else { res(); @@ -145,7 +148,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { profileStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing profile:', err); rej(err); } else { res(); @@ -179,7 +182,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { ipStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing IPs:', err); rej(err); } else { res(); @@ -214,7 +217,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { notesStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing notes:', err); rej(err); } else { res(); @@ -275,7 +278,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { followingStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing following:', err); rej(err); } else { res(); @@ -345,7 +348,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { followerStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing followers:', err); rej(err); } else { res(); @@ -406,7 +409,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { filesStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing drive:', err); rej(err); } else { res(); @@ -432,7 +435,7 @@ export class ExportAccountDataProcessorService { await this.downloadService.downloadUrl(file.url, filePath); downloaded = true; } catch (e) { - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error(`Error writing drive file ${file.id} (${file.name}): ${renderInlineError(e)}`); } if (!downloaded) { @@ -464,7 +467,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { mutingStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing mutings:', err); rej(err); } else { res(); @@ -527,7 +530,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { blockingStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing blockings:', err); rej(err); } else { res(); @@ -589,7 +592,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { favoriteStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing favorites:', err); rej(err); } else { res(); @@ -650,7 +653,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { antennaStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing antennas:', err); rej(err); } else { res(); @@ -708,7 +711,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { listStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing lists:', err); rej(err); } else { res(); @@ -744,12 +747,12 @@ export class ExportAccountDataProcessorService { zlib: { level: 0 }, }); archiveStream.on('close', async () => { - this.logger.succ(`Exported to: ${archivePath}`); + this.logger.debug(`Exported to path: ${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}`); + this.logger.debug(`Exported to drive: ${driveFile.id}`); cleanup(); archiveCleanup(); if (profile.email) { diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index b3111865ad..61d76da5ac 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -45,15 +45,19 @@ export class ExportAntennasProcessorService { public async process(job: Bull.Job): Promise { const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + + this.logger.info(`Exporting antennas of ${job.data.user.id} ...`); + 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); + this.logger.error('Error exporting antennas:', err); reject(); } else { resolve(); @@ -96,7 +100,7 @@ export class ExportAntennasProcessorService { 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); + this.logger.debug('Exported to: ' + driveFile.id); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'antenna', diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index ecc439db69..4c17c3f718 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -40,17 +40,18 @@ export class ExportBlockingProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + this.logger.info(`Exporting blocking of ${job.data.user.id} ...`); + // Create temp file const [path, cleanup] = await createTemp(); - this.logger.info(`Temp file is ${path}`); + this.logger.debug(`Temp file is ${path}`); try { const stream = fs.createWriteStream(path, { flags: 'a' }); @@ -87,7 +88,7 @@ export class ExportBlockingProcessorService { await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting blocking:', err); rej(err); } else { res(); @@ -105,12 +106,12 @@ export class ExportBlockingProcessorService { } stream.end(); - this.logger.succ(`Exported to: ${path}`); + this.logger.debug(`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}`); + this.logger.debug(`Exported to: ${driveFile.id}`); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'blocking', diff --git a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts index 583ddbb745..1d34d2b4e6 100644 --- a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts @@ -51,17 +51,18 @@ export class ExportClipsProcessorService { @bindThis public async process(job: Bull.Job): Promise { - this.logger.info(`Exporting clips of ${job.data.user.id} ...`); - const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + this.logger.info(`Exporting clips of ${job.data.user.id} ...`); + // Create temp file const [path, cleanup] = await createTemp(); - this.logger.info(`Temp file is ${path}`); + this.logger.debug(`Temp file is ${path}`); try { const stream = Writable.toWeb(fs.createWriteStream(path, { flags: 'a' })); @@ -75,12 +76,12 @@ export class ExportClipsProcessorService { await writer.write(']'); await writer.close(); - this.logger.succ(`Exported to: ${path}`); + this.logger.debug(`Exported to: ${path}`); const fileName = 'clips-' + 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}`); + this.logger.debug(`Exported to: ${driveFile.id}`); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'clip', diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index 14d32e78b3..b8f208bbfc 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -45,16 +45,17 @@ export class ExportCustomEmojisProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + this.logger.info(`Exporting custom emojis of ${job.data.user.id} ...`); + const [path, cleanup] = await createTempDir(); - this.logger.info(`Temp dir is ${path}`); + this.logger.debug(`Temp dir is ${path}`); const metaPath = path + '/meta.json'; @@ -66,7 +67,7 @@ export class ExportCustomEmojisProcessorService { return new Promise((res, rej) => { metaStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting custom emojis:', err); rej(err); } else { res(); @@ -101,7 +102,7 @@ export class ExportCustomEmojisProcessorService { await this.downloadService.downloadUrl(emoji.originalUrl, emojiPath); downloaded = true; } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error exporting custom emojis:', e as Error); } if (!downloaded) { @@ -130,12 +131,12 @@ export class ExportCustomEmojisProcessorService { zlib: { level: 0 }, }); archiveStream.on('close', async () => { - this.logger.succ(`Exported to: ${archivePath}`); + this.logger.debug(`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}`); + this.logger.debug(`Exported to: ${driveFile.id}`); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'customEmoji', diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index b81feece01..b5716f2d49 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -45,17 +45,18 @@ export class ExportFavoritesProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + this.logger.info(`Exporting favorites of ${job.data.user.id} ...`); + // Create temp file const [path, cleanup] = await createTemp(); - this.logger.info(`Temp file is ${path}`); + this.logger.debug(`Temp file is ${path}`); try { const stream = fs.createWriteStream(path, { flags: 'a' }); @@ -64,7 +65,7 @@ export class ExportFavoritesProcessorService { return new Promise((res, rej) => { stream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting favorites:', err); rej(err); } else { res(); @@ -119,12 +120,12 @@ export class ExportFavoritesProcessorService { await write(']'); stream.end(); - this.logger.succ(`Exported to: ${path}`); + this.logger.debug(`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}`); + this.logger.debug(`Exported to: ${driveFile.id}`); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'favorite', diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 903f962515..883f35e366 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -44,17 +44,18 @@ export class ExportFollowingProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + this.logger.info(`Exporting following of ${job.data.user.id} ...`); + // Create temp file const [path, cleanup] = await createTemp(); - this.logger.info(`Temp file is ${path}`); + this.logger.debug(`Temp file is ${path}`); try { const stream = fs.createWriteStream(path, { flags: 'a' }); @@ -98,7 +99,7 @@ export class ExportFollowingProcessorService { await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting following:', err); rej(err); } else { res(); @@ -109,12 +110,12 @@ export class ExportFollowingProcessorService { } stream.end(); - this.logger.succ(`Exported to: ${path}`); + this.logger.debug(`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}`); + this.logger.debug(`Exported to: ${driveFile.id}`); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'following', diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index f9867ade29..9cdb94beaf 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -40,17 +40,18 @@ export class ExportMutingProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + this.logger.debug(`Exporting muting of ${job.data.user.id} ...`); + // Create temp file const [path, cleanup] = await createTemp(); - this.logger.info(`Temp file is ${path}`); + this.logger.debug(`Temp file is ${path}`); try { const stream = fs.createWriteStream(path, { flags: 'a' }); @@ -88,7 +89,7 @@ export class ExportMutingProcessorService { await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting mutings:', err); rej(err); } else { res(); @@ -106,12 +107,12 @@ export class ExportMutingProcessorService { } stream.end(); - this.logger.succ(`Exported to: ${path}`); + this.logger.debug(`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}`); + this.logger.debug(`Exported to: ${driveFile.id}`); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'muting', diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 9e2b678219..7d49a8dab2 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -120,17 +120,18 @@ export class ExportNotesProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + this.logger.info(`Exporting notes of ${job.data.user.id} ...`); + // Create temp file const [path, cleanup] = await createTemp(); - this.logger.info(`Temp file is ${path}`); + this.logger.debug(`Temp file is ${path}`); try { // メモリが足りなくならないようにストリームで処理する @@ -146,12 +147,12 @@ export class ExportNotesProcessorService { .pipeThrough(new TextEncoderStream()) .pipeTo(new FileWriterStream(path)); - this.logger.succ(`Exported to: ${path}`); + this.logger.debug(`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}`); + this.logger.debug(`Exported to: ${driveFile.id}`); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'note', diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index c483d79854..43043e3a26 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -43,13 +43,14 @@ export class ExportUserListsProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } + this.logger.info(`Exporting user lists of ${job.data.user.id} ...`); + const lists = await this.userListsRepository.findBy({ userId: user.id, }); @@ -57,7 +58,7 @@ export class ExportUserListsProcessorService { // Create temp file const [path, cleanup] = await createTemp(); - this.logger.info(`Temp file is ${path}`); + this.logger.debug(`Temp file is ${path}`); try { const stream = fs.createWriteStream(path, { flags: 'a' }); @@ -74,7 +75,7 @@ export class ExportUserListsProcessorService { await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting lists:', err); rej(err); } else { res(); @@ -85,12 +86,12 @@ export class ExportUserListsProcessorService { } stream.end(); - this.logger.succ(`Exported to: ${path}`); + this.logger.debug(`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}`); + this.logger.debug(`Exported to: ${driveFile.id}`); this.notificationService.createNotification(user.id, 'exportCompleted', { exportedEntity: 'userList', diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index 9c033b73e2..f29a19ce66 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -8,7 +8,7 @@ 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 type { AntennasRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; @@ -59,6 +59,9 @@ export class ImportAntennasProcessorService { @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private queueLoggerService: QueueLoggerService, private idService: IdService, private globalEventService: GlobalEventService, @@ -68,12 +71,20 @@ export class ImportAntennasProcessorService { @bindThis public async process(job: Bull.Job): Promise { + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); + return; + } + + this.logger.debug(`Importing blocking of ${job.data.user.id} ...`); + 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'); + this.logger.warn('Antenna validation failed'); continue; } const result = await this.antennasRepository.insertOne({ @@ -92,11 +103,11 @@ export class ImportAntennasProcessorService { withReplies: antenna.withReplies, withFile: antenna.withFile, }); - this.logger.succ('Antenna created: ' + result.id); + this.logger.debug('Antenna created: ' + result.id); this.globalEventService.publishInternalEvent('antennaCreated', result); } } catch (err: any) { - this.logger.error(err); + this.logger.error('Error importing antennas:', err); } } } diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts index b78229c648..e2de9532eb 100644 --- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts @@ -40,10 +40,9 @@ export class ImportBlockingProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } @@ -51,14 +50,17 @@ export class ImportBlockingProcessorService { id: job.data.fileId, }); if (file == null) { + this.logger.debug(`Skip: file ${job.data.fileId} does not exist`); return; } + this.logger.debug(`Importing blocking of ${job.data.user.id} ...`); + 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'); + this.logger.debug('Import jobs created'); } @bindThis @@ -93,11 +95,11 @@ export class ImportBlockingProcessorService { // skip myself if (target.id === job.data.user.id) return; - this.logger.info(`Block ${target.id} ...`); + this.logger.debug(`Block ${target.id} ...`); this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]); } catch (e) { - this.logger.warn(`Error: ${e}`); + this.logger.error('Error importing blockings:', e as Error); } } } diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 14db4fc7bf..b55406707a 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import { ZipReader } from 'slacc'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { EmojisRepository, DriveFilesRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; @@ -15,6 +16,7 @@ import { DriveService } from '@/core/DriveService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; @@ -44,18 +46,19 @@ export class ImportCustomEmojisProcessorService { @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) { + this.logger.debug(`Skip: file ${job.data.fileId} does not exist`); return; } + this.logger.info(`Importing custom emojis from ${file.id} (${file.name}) ...`); + const [path, cleanup] = await createTempDir(); - this.logger.info(`Temp dir is ${path}`); + this.logger.debug(`Temp dir is ${path}`); const destPath = path + '/emojis.zip'; @@ -64,14 +67,14 @@ export class ImportCustomEmojisProcessorService { await this.downloadService.downloadUrl(file.url, destPath, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize }); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); + this.logger.error('Error importing custom emojis:', e as Error); } throw e; } const outputPath = path + '/emojis'; try { - this.logger.succ(`Unzipping to ${outputPath}`); + this.logger.debug(`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); @@ -91,6 +94,7 @@ export class ImportCustomEmojisProcessorService { const emojiPath = outputPath + '/' + record.fileName; await this.emojisRepository.delete({ name: nameNfc, + host: IsNull(), }); try { @@ -116,7 +120,7 @@ export class ImportCustomEmojisProcessorService { }); } catch (e) { if (e instanceof Error || typeof e === 'string') { - this.logger.error(`couldn't import ${emojiPath} for ${emojiInfo.name}: ${e}`); + this.logger.error(`couldn't import ${emojiPath} for ${emojiInfo.name}: ${renderInlineError(e)}`); } continue; } @@ -124,11 +128,9 @@ export class ImportCustomEmojisProcessorService { cleanup(); - this.logger.succ('Imported'); + this.logger.debug('Imported'); } catch (e) { - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing custom emojis:', e as Error); cleanup(); throw e; } diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts index 70c9f3a096..816d5cf65a 100644 --- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts @@ -40,10 +40,9 @@ export class ImportFollowingProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } @@ -51,14 +50,17 @@ export class ImportFollowingProcessorService { id: job.data.fileId, }); if (file == null) { + this.logger.debug(`Skip: file ${job.data.fileId} does not exist`); return; } + this.logger.info(`Importing following of ${job.data.user.id} ...`); + 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'); + this.logger.debug('Import jobs created'); } @bindThis @@ -93,11 +95,11 @@ export class ImportFollowingProcessorService { // skip myself if (target.id === job.data.user.id) return; - this.logger.info(`Follow ${target.id} ${job.data.withReplies ? 'with replies' : 'without replies'} ...`); + this.logger.debug(`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}`); + this.logger.error('Error importing followings:', e as Error); } } } diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts index ec9d2b6c4c..d3827b12fd 100644 --- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts @@ -14,6 +14,7 @@ import { DownloadService } from '@/core/DownloadService.js'; import { UserMutingService } from '@/core/UserMutingService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; @@ -40,10 +41,9 @@ export class ImportMutingProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } @@ -51,9 +51,12 @@ export class ImportMutingProcessorService { id: job.data.fileId, }); if (file == null) { + this.logger.debug(`Skip: file ${job.data.fileId} does not exist`); return; } + this.logger.info(`Importing muting of ${job.data.user.id} ...`); + const csv = await this.downloadService.downloadTextFile(file.url); let linenum = 0; @@ -88,14 +91,14 @@ export class ImportMutingProcessorService { // skip myself if (target.id === job.data.user.id) continue; - this.logger.info(`Mute[${linenum}] ${target.id} ...`); + this.logger.debug(`Mute[${linenum}] ${target.id} ...`); await this.userMutingService.mute(user, target); } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); + this.logger.warn(`Error in line:${linenum} ${renderInlineError(e)}`); } } - this.logger.succ('Imported'); + this.logger.debug('Imported'); } } diff --git a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts index ee9819b29f..e209855720 100644 --- a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts @@ -83,7 +83,7 @@ export class ImportNotesProcessorService { } @bindThis - private downloadUrl(url: string, path:string): Promise<{filename: string}> { + private downloadUrl(url: string, path:string): Promise<{ filename: string }> { return this.downloadService.downloadUrl(url, path, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize }); } @@ -159,10 +159,9 @@ export class ImportNotesProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } @@ -170,9 +169,12 @@ export class ImportNotesProcessorService { id: job.data.fileId, }); if (file == null) { + this.logger.debug(`Skip: file ${job.data.fileId} does not exist`); return; } + this.logger.info(`Starting note import of ${job.data.user.id} ...`); + 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 }); @@ -184,7 +186,7 @@ export class ImportNotesProcessorService { if (type === 'Twitter' || file.name.startsWith('twitter') && file.name.endsWith('.zip')) { const [path, cleanup] = await createTempDir(); - this.logger.info(`Temp dir is ${path}`); + this.logger.debug(`Temp dir is ${path}`); const destPath = path + '/twitter.zip'; @@ -192,15 +194,13 @@ export class ImportNotesProcessorService { await fsp.writeFile(destPath, '', 'binary'); await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing notes:', e as Error); throw e; } const outputPath = path + '/twitter'; try { - this.logger.succ(`Unzipping to ${outputPath}`); + this.logger.debug(`Unzipping to ${outputPath}`); ZipReader.withDestinationPath(outputPath).viaBuffer(await fsp.readFile(destPath)); const unprocessedTweets = this.parseTwitterFile(await fsp.readFile(outputPath + '/data/tweets.js', 'utf-8')); @@ -214,7 +214,7 @@ export class ImportNotesProcessorService { } else if (type === 'Facebook' || file.name.startsWith('facebook-') && file.name.endsWith('.zip')) { const [path, cleanup] = await createTempDir(); - this.logger.info(`Temp dir is ${path}`); + this.logger.debug(`Temp dir is ${path}`); const destPath = path + '/facebook.zip'; @@ -222,15 +222,13 @@ export class ImportNotesProcessorService { await fsp.writeFile(destPath, '', 'binary'); await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing notes:', e as Error); throw e; } const outputPath = path + '/facebook'; try { - this.logger.succ(`Unzipping to ${outputPath}`); + this.logger.debug(`Unzipping to ${outputPath}`); ZipReader.withDestinationPath(outputPath).viaBuffer(await fsp.readFile(destPath)); const postsJson = await fsp.readFile(outputPath + '/your_activity_across_facebook/posts/your_posts__check_ins__photos_and_videos_1.json', 'utf-8'); const posts = JSON.parse(postsJson); @@ -247,7 +245,7 @@ export class ImportNotesProcessorService { } else if (file.name.endsWith('.zip')) { const [path, cleanup] = await createTempDir(); - this.logger.info(`Temp dir is ${path}`); + this.logger.debug(`Temp dir is ${path}`); const destPath = path + '/unknown.zip'; @@ -255,15 +253,13 @@ export class ImportNotesProcessorService { await fsp.writeFile(destPath, '', 'binary'); await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing notes:', e as Error); throw e; } const outputPath = path + '/unknown'; try { - this.logger.succ(`Unzipping to ${outputPath}`); + this.logger.debug(`Unzipping to ${outputPath}`); ZipReader.withDestinationPath(outputPath).viaBuffer(await fsp.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'); @@ -307,15 +303,13 @@ export class ImportNotesProcessorService { } 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}`); + this.logger.debug(`Temp dir is ${path}`); try { await fsp.writeFile(path, '', 'utf-8'); await this.downloadUrl(file.url, path); } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing notes:', e as Error); throw e; } @@ -326,7 +320,7 @@ export class ImportNotesProcessorService { cleanup(); } - this.logger.succ('Import jobs created'); + this.logger.debug('Import jobs created'); } @bindThis @@ -365,7 +359,7 @@ export class ImportNotesProcessorService { try { await this.downloadUrl(file.url, filePath); } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error importing notes:', e as Error); } const driveFile = await this.driveService.addFile({ user: user, @@ -504,7 +498,7 @@ export class ImportNotesProcessorService { try { await this.downloadUrl(file.url, filePath); } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error importing notes:', e as Error); } const driveFile = await this.driveService.addFile({ user: user, @@ -628,7 +622,7 @@ export class ImportNotesProcessorService { try { await this.downloadUrl(videos[0].url, filePath); } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error importing notes:', e as Error); } const driveFile = await this.driveService.addFile({ user: user, @@ -653,7 +647,7 @@ export class ImportNotesProcessorService { try { await this.downloadUrl(file.media_url_https, filePath); } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error importing notes:', e as Error); } const driveFile = await this.driveService.addFile({ @@ -673,7 +667,7 @@ export class ImportNotesProcessorService { 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}`); + this.logger.error('Error importing notes:', e as Error); } } diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts index db9255b35d..482054e52f 100644 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -15,6 +15,7 @@ import { UserListService } from '@/core/UserListService.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; @@ -48,10 +49,9 @@ export class ImportUserListsProcessorService { @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) { + this.logger.debug(`Skip: user ${job.data.user.id} does not exist`); return; } @@ -59,9 +59,12 @@ export class ImportUserListsProcessorService { id: job.data.fileId, }); if (file == null) { + this.logger.debug(`Skip: file ${job.data.fileId} does not exist`); return; } + this.logger.info(`Importing user lists of ${job.data.user.id} ...`); + const csv = await this.downloadService.downloadTextFile(file.url); let linenum = 0; @@ -102,10 +105,10 @@ export class ImportUserListsProcessorService { this.userListService.addMember(target, list!, user); } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); + this.logger.warn(`Error in line:${linenum} ${renderInlineError(e)}`); } } - this.logger.succ('Imported'); + this.logger.debug('Imported'); } } diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 35a0bf095d..5f82d558b3 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -21,17 +21,18 @@ 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 { JsonLdService } from '@/core/activitypub/JsonLdService.js'; +import { isSigned, JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { CollapsedQueue } from '@/misc/collapsed-queue.js'; -import { MiNote } from '@/models/Note.js'; import { MiMeta } from '@/models/Meta.js'; import { DI } from '@/di-symbols.js'; import { SkApInboxLog } from '@/models/_.js'; import type { Config } from '@/config.js'; import { ApLogService, calculateDurationSince } from '@/core/ApLogService.js'; +import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; +import { isRetryableError } from '@/misc/is-retryable-error.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { InboxJobData } from '../types.js'; @@ -43,7 +44,7 @@ type UpdateInstanceJob = { @Injectable() export class InboxProcessorService implements OnApplicationShutdown { private logger: Logger; - private updateInstanceQueue: CollapsedQueue; + //private updateInstanceQueue: CollapsedQueue; constructor( @Inject(DI.meta) @@ -64,9 +65,9 @@ export class InboxProcessorService implements OnApplicationShutdown { private federationChart: FederationChart, private queueLoggerService: QueueLoggerService, private readonly apLogService: ApLogService, + private readonly updateInstanceQueue: UpdateInstanceQueue, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); - this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance); } @bindThis @@ -126,6 +127,14 @@ export class InboxProcessorService implements OnApplicationShutdown { return `Old keyId is no longer supported. ${keyIdLower}`; } + if (activity.actor as unknown == null || (Array.isArray(activity.actor) && activity.actor.length < 1)) { + return 'skip: activity has no actor'; + } + if (typeof(activity.actor) !== 'string' && typeof(activity.actor) !== 'object') { + return `skip: activity actor has invalid type: ${typeof(activity.actor)}`; + } + const actorId = getApId(activity.actor); + // HTTP-Signature keyIdを元にDBから取得 let authUser: { user: MiRemoteUser; @@ -135,26 +144,25 @@ export class InboxProcessorService implements OnApplicationShutdown { // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 if (authUser == null) { try { - authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); + authUser = await this.apDbResolverService.getAuthUserFromApId(actorId); } catch (err) { // 対象が4xxならスキップ - if (err instanceof StatusError) { - if (!err.isRetryable) { - 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 (!isRetryableError(err)) { + throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${actorId}`); } + + throw err; } } // それでもわからなければ終了 if (authUser == null) { - throw new Bull.UnrecoverableError('skip: failed to resolve user'); + throw new Bull.UnrecoverableError(`skip: failed to resolve user ${actorId}`); } // publicKey がなくても終了 if (authUser.key == null) { - throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey'); + throw new Bull.UnrecoverableError(`skip: failed to resolve user publicKey ${actorId}`); } // HTTP-Signatureの検証 @@ -169,10 +177,10 @@ export class InboxProcessorService implements OnApplicationShutdown { } // また、signatureのsignerは、activity.actorと一致する必要がある - if (!httpSignatureValidated || authUser.user.uri !== getApId(activity.actor)) { + if (!httpSignatureValidated || authUser.user.uri !== actorId) { // 一致しなくても、でもLD-Signatureがありそうならそっちも見る - const ldSignature = activity.signature; - if (ldSignature) { + if (isSigned(activity)) { + const ldSignature = activity.signature; if (ldSignature.type !== 'RsaSignature2017') { throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`); } @@ -194,33 +202,30 @@ export class InboxProcessorService implements OnApplicationShutdown { throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'); } - const jsonLd = this.jsonLdService.use(); - // LD-Signature検証 - const verified = await jsonLd.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); + const verified = await this.jsonLdService.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); if (!verified) { throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); } // アクティビティを正規化 - delete activity.signature; + const copy = { ...activity, signature: undefined }; try { - activity = await jsonLd.compact(activity) as IActivity; + activity = await this.jsonLdService.compact(copy) as IActivity; } catch (e) { throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${e}`); } // TODO: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする // https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29 - activity.signature = ldSignature; // もう一度actorチェック - if (authUser.user.uri !== activity.actor) { - throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); + if (authUser.user.uri !== actorId) { + throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${actorId})`); } const ldHost = this.utilityService.extractDbHost(authUser.user.uri); if (!this.utilityService.isFederationAllowedHost(ldHost)) { - throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); + throw new Bull.UnrecoverableError(`skip: request host is blocked: ${ldHost}`); } } else { throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`); @@ -232,7 +237,7 @@ export class InboxProcessorService implements OnApplicationShutdown { 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}`); + throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost})`); } } else { // Activity ID should only be string or undefined. @@ -293,16 +298,8 @@ export class InboxProcessorService implements OnApplicationShutdown { } } - if (e instanceof StatusError && !e.isRetryable) { - return `skip: permanent error ${e.statusCode}`; - } - - if (e instanceof IdentifiableError && !e.isRetryable) { - if (e.message) { - return `skip: permanent error ${e.id}: ${e.message}`; - } else { - return `skip: permanent error ${e.id}`; - } + if (!isRetryableError(e)) { + return `skip: permanent error ${renderInlineError(e)}`; } throw e; @@ -333,9 +330,7 @@ export class InboxProcessorService implements OnApplicationShutdown { } @bindThis - public async dispose(): Promise { - await this.updateInstanceQueue.performAllNow(); - } + public async dispose(): Promise {} @bindThis async onApplicationShutdown(signal?: string) { diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts index 0c47fdedb3..5b7a871af9 100644 --- a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts @@ -36,6 +36,6 @@ export class ResyncChartsProcessorService { await this.notesChart.resync(); await this.usersChart.resync(); - this.logger.succ('All charts successfully resynced.'); + this.logger.info('All charts successfully resynced.'); } } diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts index d823d98ef1..73088f3312 100644 --- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts +++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts @@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js'; import { NotificationService } from '@/core/NotificationService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiScheduleNoteType } from '@/models/NoteSchedule.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { ScheduleNotePostJobData } from '../types.js'; @@ -129,10 +130,11 @@ export class ScheduleNotePostProcessorService { channel, }).catch(async (err: IdentifiableError) => { this.notificationService.createNotification(me.id, 'scheduledNoteFailed', { - reason: err.message, + reason: renderInlineError(err), }); await this.noteScheduleRepository.remove(data); - throw this.logger.error(`Schedule Note Failed Reason: ${err.message}`); + this.logger.error(`Scheduled note failed: ${renderInlineError(err)}`); + throw err; }); await this.noteScheduleRepository.remove(data); this.notificationService.createNotification(me.id, 'scheduledNotePosted', { diff --git a/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts index f6bef52684..f9fcd1e928 100644 --- a/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts @@ -12,6 +12,7 @@ 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 { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import { SystemWebhookDeliverJobData } from '../types.js'; @@ -63,21 +64,16 @@ export class SystemWebhookDeliverProcessorService { return 'Success'; } catch (res) { - this.logger.error(res as Error); + this.logger.error(`Failed to send webhook: ${renderInlineError(res)}`); this.systemWebhooksRepository.update({ id: job.data.webhookId }, { latestSentAt: new Date(), latestStatus: res instanceof StatusError ? res.statusCode : 1, }); - if (res instanceof StatusError) { + if (res instanceof StatusError && !res.isRetryable) { // 4xx - if (!res.isRetryable) { - throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); - } - - // 5xx etc. - throw new Error(`${res.statusCode} ${res.statusMessage}`); + throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); } else { // DNS error, socket error, timeout ... throw res; diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts index fc8856a271..b4b8b1f205 100644 --- a/packages/backend/src/queue/processors/TickChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/TickChartsProcessorService.ts @@ -62,6 +62,6 @@ export class TickChartsProcessorService { await this.perUserDriveChart.tick(false); await this.apRequestChart.tick(false); - this.logger.succ('All charts successfully ticked.'); + this.logger.info('All charts successfully ticked.'); } } diff --git a/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts index 9ec630ef70..0208ce6038 100644 --- a/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts @@ -69,14 +69,9 @@ export class UserWebhookDeliverProcessorService { latestStatus: res instanceof StatusError ? res.statusCode : 1, }); - if (res instanceof StatusError) { + if (res instanceof StatusError && !res.isRetryable) { // 4xx - if (!res.isRetryable) { - throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); - } - - // 5xx etc. - throw new Error(`${res.statusCode} ${res.statusMessage}`); + throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); } else { // DNS error, socket error, timeout ... throw res; diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index a900675a86..1bd9f7a0ab 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -38,7 +38,7 @@ export type RelationshipJobData = { silent?: boolean; requestId?: string; withReplies?: boolean; -} +}; export type DbJobData = DbJobMap[T]; @@ -69,11 +69,11 @@ export type DbJobMap = { importUserLists: DbUserImportJobData; importCustomEmojis: DbUserImportJobData; deleteAccount: DbUserDeleteJobData; -} +}; export type DbJobDataWithUser = { user: ThinUser; -} +}; export type DbExportFollowingData = { user: ThinUser; @@ -83,7 +83,7 @@ export type DbExportFollowingData = { export type DBExportAntennasData = { user: ThinUser -} +}; export type DbUserDeleteJobData = { user: ThinUser; @@ -105,7 +105,7 @@ export type DbNoteImportJobData = { export type DBAntennaImportJobData = { user: ThinUser, antenna: Antenna -} +}; export type DbUserImportToDbJobData = { user: ThinUser; @@ -161,4 +161,4 @@ export type ThinUser = { export type ScheduleNotePostJobData = { scheduleNoteId: MiNote['id']; -} +}; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 10dba1660f..27d25d2152 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -14,7 +14,7 @@ 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 type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -22,7 +22,6 @@ import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { QueueService } from '@/core/QueueService.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.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'; @@ -33,11 +32,13 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { IActivity } from '@/core/activitypub/type.js'; -import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js'; +import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; import * as Acct from '@/misc/acct.js'; +import { CacheService } from '@/core/CacheService.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.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'; @@ -51,6 +52,9 @@ export class ActivityPubServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -77,13 +81,14 @@ export class ActivityPubServerService { 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 fanoutTimelineEndpointService: FanoutTimelineEndpointService, private loggerService: LoggerService, + private readonly cacheService: CacheService, ) { //this.createServer = this.createServer.bind(this); this.logger = this.loggerService.getLogger('apserv', 'pink'); @@ -106,7 +111,7 @@ export class ActivityPubServerService { * @param author Author of the note */ @bindThis - private async packActivity(note: MiNote, author: MiUser): Promise { + private async packActivity(note: MiNote, author: MiUser): Promise { if (isRenote(note) && !isQuote(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); @@ -115,10 +120,55 @@ export class ActivityPubServerService { return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note); } - @bindThis - private async shouldRefuseGetRequest(request: FastifyRequest, reply: FastifyReply, userId: string | undefined = undefined): Promise { - if (!this.config.checkActivityPubGetSignature) return false; + /** + * Checks Authorized Fetch. + * Returns an object with two properties: + * * reject - true if the request should be ignored by the caller, false if it should be processed. + * * redact - true if the caller should redact response data, false if it should return full data. + * When "reject" is true, the HTTP status code will be automatically set to 401 unauthorized. + */ + private async checkAuthorizedFetch( + request: FastifyRequest, + reply: FastifyReply, + userId?: string, + essential?: boolean, + ): Promise<{ reject: boolean, redact: boolean }> { + // Federation disabled => reject + if (this.meta.federation === 'none') { + reply.code(401); + return { reject: true, redact: true }; + } + // Auth fetch disabled => accept + const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId); + if (allowUnsignedFetch === 'always') { + return { reject: false, redact: false }; + } + + // Valid signature => accept + const error = await this.checkSignature(request); + if (!error) { + return { reject: false, redact: false }; + } + + // Unsigned, but essential => accept redacted + if (allowUnsignedFetch === 'essential' && essential) { + return { reject: false, redact: true }; + } + + // Unsigned, not essential => reject + this.authlogger.warn(error); + reply.code(401); + return { reject: true, redact: true }; + } + + /** + * Verifies HTTP Signatures for a request. + * Returns null of success (valid signature). + * Returns a string error on validation failure. + */ + @bindThis + private async checkSignature(request: FastifyRequest): Promise { /* this code is inspired from the `inbox` function below, and `queue/processors/InboxProcessorService` @@ -129,59 +179,33 @@ export class ActivityPubServerService { 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; + let signature: httpSignature.IParsedSignature; try { - signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); + signature = httpSignature.parseRequest(request.raw, { + headers: ['(request-target)', 'host', 'date'], + authorizationHeaderName: 'signature', + }); } 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; + return `${request.id} ${request.url} not signed, or malformed signature: refuse`; } const keyId = new URL(signature.keyId); const keyHost = this.utilityService.toPuny(keyId.hostname); - const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) apparently from ${keyHost}:`; + const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) claims to be from ${keyHost}:`; - if (signature.params.headers.indexOf('host') === -1 - || request.headers.host !== this.config.host) { + if (signature.params.headers.indexOf('host') === -1 || request.headers.host !== this.config.host) { // no destination host, or not us: refuse - this.authlogger.warn(`${logPrefix} no destination host, or not us: refuse`); - reply.code(401); - return true; + return `${logPrefix} no destination host, or not us: refuse`; } if (!this.utilityService.isFederationAllowedHost(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(`${logPrefix} instance is blocked: refuse`); - reply.code(401); - return true; + return `${logPrefix} instance is blocked: refuse`; } // do we know the signer already? @@ -200,34 +224,64 @@ export class ActivityPubServerService { if (authUser?.key == null) { // we can't figure out who the signer is, or we can't get their key: refuse - this.authlogger.warn(`${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`); - reply.code(401); - return true; + return `${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`; } - let httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + if (authUser.user.isSuspended) { + // Signer is suspended locally + return `${logPrefix} signer is suspended: refuse`; + } + + // some fedi implementations include the query (`?foo=bar`) in the + // signature, some don't, so we have to handle both cases + function verifyWithOrWithoutQuery() { + const httpSignatureValidated = httpSignature.verifySignature(signature, authUser!.key!.keyPem); + if (httpSignatureValidated) return true; + + const requestUrl = new URL(`http://whatever${request.raw.url}`); + if (! requestUrl.search) return false; + + // verification failed, the request URL contained a query, let's try without + const semiRawRequest = request.raw; + semiRawRequest.url = requestUrl.pathname; + + // no need for try/catch, if the original request parsed, this + // one will, too + const signatureWithoutQuery = httpSignature.parseRequest(semiRawRequest, { + headers: ['(request-target)', 'host', 'date'], + authorizationHeaderName: 'signature', + }); + + return httpSignature.verifySignature(signatureWithoutQuery, authUser!.key!.keyPem); + } + + let httpSignatureValidated = verifyWithOrWithoutQuery(); // maybe they changed their key? refetch it + // TODO rate-limit this using lastFetchedAt if (!httpSignatureValidated) { authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user); if (authUser.key != null) { - httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + httpSignatureValidated = verifyWithOrWithoutQuery(); } } if (!httpSignatureValidated) { // bad signature: refuse - this.authlogger.info(`${logPrefix} failed to validate signature: refuse`); - reply.code(401); - return true; + return `${logPrefix} failed to validate signature: refuse`; } // all good, don't refuse - return false; + return null; } @bindThis private inbox(request: FastifyRequest, reply: FastifyReply) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + let signature; try { @@ -299,7 +353,13 @@ export class ActivityPubServerService { request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, reply: FastifyReply, ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -326,11 +386,9 @@ export class ActivityPubServerService { 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 @@ -382,7 +440,6 @@ export class ActivityPubServerService { 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)); } @@ -393,7 +450,13 @@ export class ActivityPubServerService { request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, reply: FastifyReply, ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -420,11 +483,9 @@ export class ActivityPubServerService { 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 @@ -476,7 +537,6 @@ export class ActivityPubServerService { 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)); } @@ -484,7 +544,13 @@ export class ActivityPubServerService { @bindThis private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -505,7 +571,7 @@ export class ActivityPubServerService { const pinnedNotes = (await Promise.all(pinings.map(pining => this.notesRepository.findOneByOrFail({ id: pining.noteId })))) - .filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility)); + .filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility) && !isPureRenote(note)); const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user))); @@ -517,7 +583,6 @@ export class ActivityPubServerService { renderedNotes, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } @@ -530,7 +595,13 @@ export class ActivityPubServerService { }>, reply: FastifyReply, ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -567,16 +638,28 @@ export class ActivityPubServerService { 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(); + const notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({ + sinceId: sinceId ?? null, + untilId: untilId ?? null, + limit: limit, + allowPartial: false, // Possibly true? IDK it's OK for ordered collection. + me: null, + redisTimelines: [ + `userTimeline:${user.id}`, + `userTimelineWithReplies:${user.id}`, + ], + useDbFallback: true, + ignoreAuthorFromMute: true, + excludePureRenotes: false, + noteFilter: (note) => { + if (note.visibility !== 'home' && note.visibility !== 'public') return false; + if (note.localOnly) return false; + return true; + }, + dbFallback: async (untilId, sinceId, limit) => { + return await this.getUserNotesFromDb(sinceId, untilId, limit, user.id); + }, + }) : await this.getUserNotesFromDb(sinceId ?? null, untilId ?? null, limit, user.id); if (sinceId) notes.reverse(); @@ -608,14 +691,32 @@ export class ActivityPubServerService { `${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) { + private async getUserNotesFromDb(untilId: string | null, sinceId: string | null, limit: number, userId: MiUser['id']) { + return await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) + .andWhere('note.userId = :userId', { userId }) + .andWhere(new Brackets(qb => { + qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.localOnly = FALSE') + .limit(limit) + .getMany(); + } + + @bindThis + private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null, redact = false) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + if (user == null) { reply.code(404); return; @@ -631,10 +732,12 @@ export class ActivityPubServerService { 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))); + + const person = redact + ? await this.apRendererService.renderPersonRedacted(user as MiLocalUser) + : await this.apRendererService.renderPerson(user as MiLocalUser); + return this.apRendererService.addContext(person); } @bindThis @@ -687,6 +790,17 @@ export class ActivityPubServerService { reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Expose-Headers', 'Vary'); + + // Tell crawlers not to index AP endpoints. + // https://developers.google.com/search/docs/crawling-indexing/block-indexing + reply.header('X-Robots-Tag', 'noindex'); + + /* 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'); done(); }); @@ -697,16 +811,22 @@ export class ActivityPubServerService { // 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'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const note = await this.notesRepository.findOneBy({ id: request.params.note, visibility: In(['public', 'home']), localOnly: false, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId); + if (reject) return; + if (note == null) { reply.code(404); return; @@ -722,7 +842,11 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); + // Boosts don't federate directly - they should only be referenced as an activity + if (isPureRenote(note)) { + return 404; + } + this.setResponseType(request, reply); const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); @@ -731,10 +855,13 @@ export class ActivityPubServerService { // note activity fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const note = await this.notesRepository.findOneBy({ id: request.params.note, userHost: IsNull(), @@ -742,18 +869,66 @@ export class ActivityPubServerService { localOnly: false, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId); + if (reject) return; + if (note == null) { reply.code(404); return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); return (this.apRendererService.addContext(await this.packActivity(note, author))); }); + // replies + fastify.get<{ + Params: { note: string; }; + Querystring: { page?: unknown; until_id?: unknown; }; + }>('/notes/:note/replies', async (request, reply) => { + vary(reply.raw, 'Accept'); + this.setResponseType(request, reply); + + // Raw query to avoid fetching the while entity just to check access and get the user ID + const note = await this.notesRepository + .createQueryBuilder('note') + .andWhere({ + id: request.params.note, + userHost: IsNull(), + visibility: In(['public', 'home']), + localOnly: false, + }) + .select(['note.id', 'note.userId']) + .getRawOne<{ note_id: string, note_userId: string }>(); + + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.note_userId); + if (reject) return; + + if (note == null) { + reply.code(404); + return; + } + + const untilId = request.query.until_id; + if (untilId != null && typeof(untilId) !== 'string') { + reply.code(400); + return; + } + + // If page is unset, then we just provide the outer wrapper. + // This is because the spec doesn't allow the wrapper to contain both elements *and* pages. + // We could technically do it anyway, but that may break other instances. + if (request.query.page !== 'true') { + const collection = await this.apRendererService.renderRepliesCollection(note.note_id); + return this.apRendererService.addContext(collection); + } + + const page = await this.apRendererService.renderRepliesCollectionPage(note.note_id, untilId ?? undefined); + return this.apRendererService.addContext(page); + }); + // outbox fastify.get<{ Params: { user: string; }; @@ -777,7 +952,13 @@ export class ActivityPubServerService { // publickey fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user, true); + if (reject) return; const userId = request.params.user; @@ -794,7 +975,6 @@ export class ActivityPubServerService { 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 { @@ -804,10 +984,16 @@ export class ActivityPubServerService { }); fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const { reject, redact } = await this.checkAuthorizedFetch(request, reply, request.params.user, true); + if (reject) return; vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ @@ -815,29 +1001,41 @@ export class ActivityPubServerService { isSuspended: false, }); - return await this.userInfo(request, reply, user); + return await this.userInfo(request, reply, user, redact); }); fastify.get<{ Params: { acct: string; } }>('/@:acct', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.acct)) return; - vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const acct = Acct.parse(request.params.acct); const user = await this.usersRepository.findOneBy({ - usernameLower: acct.username, + usernameLower: acct.username.toLowerCase(), host: acct.host ?? IsNull(), isSuspended: false, }); - return await this.userInfo(request, reply, user); + const { reject, redact } = await this.checkAuthorizedFetch(request, reply, user?.id, true); + if (reject) return; + + return await this.userInfo(request, reply, user, redact); }); //#endregion // emoji fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply); + if (reject) return; const emoji = await this.emojisRepository.findOneBy({ host: IsNull(), @@ -849,17 +1047,22 @@ export class ActivityPubServerService { 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; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like }); + const { reject } = await this.checkAuthorizedFetch(request, reply, reaction?.userId); + if (reject) return; + if (reaction == null) { reply.code(404); return; @@ -872,14 +1075,19 @@ export class ActivityPubServerService { 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; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.follower); + if (reject) return; // This may be used before the follow is completed, so we do not // check if the following exists. @@ -900,14 +1108,16 @@ export class ActivityPubServerService { 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; + fastify.get<{ Params: { followRequestId: string; } }>('/follows/:followRequestId', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + 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. @@ -916,6 +1126,9 @@ export class ActivityPubServerService { id: request.params.followRequestId, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, followRequest?.followerId); + if (reject) return; + if (followRequest == null) { reply.code(404); return; @@ -937,11 +1150,21 @@ export class ActivityPubServerService { 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(); } + + private async getUnsignedFetchAllowance(userId: string | undefined) { + const user = userId ? await this.cacheService.findLocalUserById(userId) : null; + + // User system value if there is no user, or if user has deferred the choice. + if (!user?.allowUnsignedFetch || user.allowUnsignedFetch === 'staff') { + return this.meta.allowUnsignedFetch; + } + + return user.allowUnsignedFetch; + } } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 2cd3199567..a32ad5e2ee 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -32,6 +32,7 @@ import { getIpHash } from '@/misc/get-ip-hash.js'; import { AuthenticateService } from '@/server/api/AuthenticateService.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; const _filename = fileURLToPath(import.meta.url); @@ -69,6 +70,10 @@ export class FileServerService { 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', '*'); + + // Tell crawlers not to index files endpoints. + // https://developers.google.com/search/docs/crawling-indexing/block-indexing + reply.header('X-Robots-Tag', 'noindex'); done(); }); @@ -120,7 +125,7 @@ export class FileServerService { @bindThis private async errorHandler(request: FastifyRequest<{ Params?: { [x: string]: any }; Querystring?: { [x: string]: any }; }>, reply: FastifyReply, err?: any) { - this.logger.error(`${err}`); + this.logger.error(`Unhandled error in file server: ${renderInlineError(err)}`); reply.header('Cache-Control', 'max-age=300'); @@ -195,6 +200,10 @@ export class FileServerService { reply.header('Content-Length', file.file.size); if (!image) { + if (file.file.size > 0) { + reply.header('Accept-Ranges', 'bytes'); + } + if (request.headers.range && file.file.size > 0) { const range = request.headers.range as string; const parts = range.replace(/bytes=/, '').split('-'); @@ -215,7 +224,6 @@ export class FileServerService { }; reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); reply.header('Content-Length', chunksize); reply.code(206); } else { @@ -257,6 +265,10 @@ export class FileServerService { reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition('inline', filename)); + if (file.file.size > 0) { + reply.header('Accept-Ranges', 'bytes'); + } + if (request.headers.range && file.file.size > 0) { const range = request.headers.range as string; const parts = range.replace(/bytes=/, '').split('-'); @@ -271,7 +283,6 @@ export class FileServerService { end, }); reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); reply.header('Content-Length', chunksize); reply.code(206); return fileStream; @@ -284,6 +295,10 @@ export class FileServerService { reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition('inline', file.filename)); + if (file.file.size > 0) { + reply.header('Accept-Ranges', 'bytes'); + } + if (request.headers.range && file.file.size > 0) { const range = request.headers.range as string; const parts = range.replace(/bytes=/, '').split('-'); @@ -298,7 +313,6 @@ export class FileServerService { end, }); reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); reply.header('Content-Length', chunksize); reply.code(206); return fileStream; @@ -344,7 +358,7 @@ export class FileServerService { if (!request.headers['user-agent']) { throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { - throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); + throw new StatusError(`Refusing to proxy recursive request to ${url} (from user-agent ${request.headers['user-agent']})`, 403, 'Proxy is recursive'); } // Create temp file @@ -374,7 +388,7 @@ export class FileServerService { ) { if (!isConvertibleImage) { // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); + throw new StatusError(`Unexpected non-convertible mime: ${file.mime}`, 404, 'Unexpected mime'); } } @@ -438,10 +452,14 @@ export class FileServerService { } 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'); + throw new StatusError(`Blocked mime type: ${file.mime}`, 403, 'Blocked mime type'); } if (!image) { + if (file.file && file.file.size > 0) { + reply.header('Accept-Ranges', 'bytes'); + } + if (request.headers.range && file.file && file.file.size > 0) { const range = request.headers.range as string; const parts = range.replace(/bytes=/, '').split('-'); @@ -462,7 +480,6 @@ export class FileServerService { }; reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); reply.header('Content-Length', chunksize); reply.code(206); } else { @@ -509,7 +526,7 @@ export class FileServerService { > { 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'); + if (!key) throw new StatusError(`Invalid file URL ${url}`, 400, 'Invalid file url'); return await this.getFileFromKey(key); } @@ -519,7 +536,7 @@ export class FileServerService { @bindThis private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } + { state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } > { const [path, cleanup] = await createTemp(); try { @@ -663,9 +680,11 @@ export class FileServerService { if (info.blocked) { reply.code(429); reply.send({ - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + error: { + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + }, }); return false; diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 6dee6ecd78..55e8827696 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -8,11 +8,11 @@ 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 { SystemAccountService } from '@/core/SystemAccountService.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; const nodeinfo2_1path = '/nodeinfo/2.1'; @@ -25,7 +25,7 @@ export class NodeinfoServerService { @Inject(DI.config) private config: Config, - private userEntityService: UserEntityService, + private systemAccountService: SystemAccountService, private metaService: MetaService, private notesChart: NotesChart, private usersChart: UsersChart, @@ -69,7 +69,7 @@ export class NodeinfoServerService { const activeHalfyear = null; const activeMonth = null; - const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null; + const proxyAccount = await this.systemAccountService.fetch('proxy'); const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies }; @@ -130,7 +130,7 @@ export class NodeinfoServerService { maxRemoteAltTextLength: this.config.maxRemoteAltTextLength, enableEmail: meta.enableEmail, enableServiceWorker: meta.enableServiceWorker, - proxyAccountName: proxyAccount ? proxyAccount.username : null, + proxyAccountName: proxyAccount.username, themeColor: meta.themeColor ?? '#86b300', }, }; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 2c067afe88..8ff8da380a 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -7,6 +7,16 @@ import { Module } from '@nestjs/common'; import { EndpointsModule } from '@/server/api/EndpointsModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js'; +import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; +import { ApiFilterMastodon } from '@/server/api/mastodon/endpoints/filter.js'; +import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; +import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js'; +import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; +import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js'; +import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js'; +import { ServerUtilityService } from '@/server/ServerUtilityService.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; import { HealthServerService } from './HealthServerService.js'; @@ -19,14 +29,13 @@ 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 { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; -import { MastoConverters } from './api/mastodon/converters.js'; +import { MastodonConverters } from './api/mastodon/MastodonConverters.js'; import { MastodonLogger } from './api/mastodon/MastodonLogger.js'; import { MastodonDataService } from './api/mastodon/MastodonDataService.js'; import { FeedService } from './web/FeedService.js'; @@ -50,6 +59,8 @@ import { ServerStatsChannelService } from './api/stream/channels/server-stats.js import { UserListChannelService } from './api/stream/channels/user-list.js'; import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; +import { ChatUserChannelService } from './api/stream/channels/chat-user.js'; +import { ChatRoomChannelService } from './api/stream/channels/chat-room.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @@ -77,8 +88,6 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ApiServerService, AuthenticateService, SkRateLimiterService, - // No longer used, but kept for backwards compatibility - RateLimiterService, SigninApiService, SigninWithPasskeyApiService, SigninService, @@ -93,6 +102,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j BubbleTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, + ChatUserChannelService, + ChatRoomChannelService, ReversiChannelService, ReversiGameChannelService, HomeTimelineChannelService, @@ -104,9 +115,19 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j OpenApiServerService, MastodonApiServerService, OAuth2ProviderService, - MastoConverters, + MastodonConverters, MastodonLogger, MastodonDataService, + MastodonClientService, + ApiAccountMastodon, + ApiAppsMastodon, + ApiFilterMastodon, + ApiInstanceMastodon, + ApiNotificationsMastodon, + ApiSearchMastodon, + ApiStatusMastodon, + ApiTimelineMastodon, + ServerUtilityService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 690fdcfe29..77b4519570 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -7,7 +7,7 @@ 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 Fastify, { type FastifyInstance } from 'fastify'; import fastifyStatic from '@fastify/static'; import fastifyRawBody from 'fastify-raw-body'; import { IsNull } from 'typeorm'; @@ -21,6 +21,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -105,6 +106,43 @@ export class ServerService implements OnApplicationShutdown { serve: false, }); + // if the requester looks like to be performing an ActivityPub object lookup, reject all external redirects + // + // this will break lookup that involve copying a URL from a third-party server, like trying to lookup http://charlie.example.com/@alice@alice.com + // + // this is not required by standard but protect us from peers that did not validate final URL. + if (this.config.disallowExternalApRedirect) { + const maybeApLookupRegex = /application\/activity\+json|application\/ld\+json.+activitystreams/i; + fastify.addHook('onSend', (request, reply, _, done) => { + const location = reply.getHeader('location'); + if (reply.statusCode < 300 || reply.statusCode >= 400 || typeof location !== 'string') { + done(); + return; + } + + if (!maybeApLookupRegex.test(request.headers.accept ?? '')) { + done(); + return; + } + + const effectiveLocation = process.env.NODE_ENV === 'production' ? location : location.replace(/^http:\/\//, 'https://'); + if (effectiveLocation.startsWith(`https://${this.config.host}/`)) { + done(); + return; + } + + reply.status(406); + reply.removeHeader('location'); + reply.header('content-type', 'text/plain; charset=utf-8'); + reply.header('link', `<${encodeURI(location)}>; rel="canonical"`); + done(null, [ + "Refusing to relay remote ActivityPub object lookup.", + "", + `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`, + ].join('\n')); + }); + } + fastify.register(this.apiServerService.createServer, { prefix: '/api' }); fastify.register(this.openApiServerService.createServer); fastify.register(this.mastodonApiServerService.createServer, { prefix: '/api' }); @@ -186,18 +224,18 @@ export class ServerService implements OnApplicationShutdown { reply.header('Cache-Control', 'public, max-age=86400'); if (user) { - reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); + reply.redirect((user.avatarId == null ? null : 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'); + fastify.get<{ Params: { x: string } }>('/identicon/:x', (request, reply) => { + reply.header('Content-Type', 'image/png'); reply.header('Cache-Control', 'public, max-age=86400'); if (this.meta.enableIdenticonGeneration) { - return await genIdenticon(request.params.x); + return genIdenticon(request.params.x); } else { return reply.redirect('/static-assets/avatar.png'); } @@ -240,7 +278,7 @@ export class ServerService implements OnApplicationShutdown { this.logger.error(`Port ${this.config.port} is already in use by another process.`); break; default: - this.logger.error(err); + this.logger.error(`Unhandled error in server: ${renderInlineError(err)}`); break; } @@ -256,13 +294,14 @@ export class ServerService implements OnApplicationShutdown { 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); - } - }); + + await fastify.listen({ path: this.config.socket }); + + if (this.config.chmodSocket) { + fs.chmodSync(this.config.socket!, this.config.chmodSocket); + } } else { - fastify.listen({ port: this.config.port, host: this.config.address }); + await fastify.listen({ port: this.config.port, host: this.config.address }); } await fastify.ready(); @@ -274,6 +313,13 @@ export class ServerService implements OnApplicationShutdown { await this.#fastify.close(); } + /** + * Get the Fastify instance for testing. + */ + public get fastify(): FastifyInstance { + return this.#fastify; + } + @bindThis async onApplicationShutdown(signal: string): Promise { await this.dispose(); diff --git a/packages/backend/src/server/ServerUtilityService.ts b/packages/backend/src/server/ServerUtilityService.ts new file mode 100644 index 0000000000..00eb97f679 --- /dev/null +++ b/packages/backend/src/server/ServerUtilityService.ts @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import querystring from 'querystring'; +import multipart from '@fastify/multipart'; +import { Inject, Injectable } from '@nestjs/common'; +import { FastifyInstance } from 'fastify'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { saveToTempFile } from '@/misc/create-temp.js'; + +@Injectable() +export class ServerUtilityService { + constructor( + @Inject(DI.config) + private readonly config: Config, + ) {} + + public addMultipartFormDataContentType(fastify: FastifyInstance): void { + fastify.register(multipart, { + limits: { + fileSize: this.config.maxFileSize, + files: 1, + }, + }); + + // Default behavior saves files to memory - we don't want that! + // Store to temporary file instead, and copy the body fields while we're at it. + fastify.addHook<{ Body?: Record }>('preValidation', async request => { + if (request.isMultipart()) { + // We can't use saveRequestFiles() because it erases all the data fields. + // Instead, recreate it manually. + // https://github.com/fastify/fastify-multipart/issues/549 + + for await (const part of request.parts()) { + if (part.type === 'field') { + const k = part.fieldname; + const v = part.value; + const body = request.body ??= {}; + + // Value can be string, buffer, or undefined. + // We only support the first one. + if (typeof(v) !== 'string') continue; + + // This is just progressive conversion from undefined -> string -> string[] + if (!body[k]) { + body[k] = v; + } else if (Array.isArray(body[k])) { + body[k].push(v); + } else { + body[k] = [body[k], v]; + } + } else { // Otherwise it's a file + try { + const [filepath] = await saveToTempFile(part.file); + + const tmpUploads = (request.tmpUploads ??= []); + tmpUploads.push(filepath); + + const requestSavedFiles = (request.savedRequestFiles ??= []); + requestSavedFiles.push({ + ...part, + filepath, + }); + } catch (e) { + // Cleanup to avoid file leak in case of errors + await request.cleanRequestFiles(); + request.tmpUploads = null; + request.savedRequestFiles = null; + throw e; + } + } + } + } + }); + } + + public addFormUrlEncodedContentType(fastify: FastifyInstance) { + fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => { + let body = ''; + payload.on('data', (data) => { + body += data; + }); + payload.on('end', () => { + try { + const parsed = querystring.parse(body); + done(null, parsed); + } catch (e) { + done(e as Error); + } + }); + payload.on('error', done); + }); + } + + public addCORS(fastify: FastifyInstance) { + fastify.addHook('preHandler', (_, reply, done) => { + // Allow web-based clients to connect from other origins. + reply.header('Access-Control-Allow-Origin', '*'); + + // Mastodon uses all types of request methods. + reply.header('Access-Control-Allow-Methods', '*'); + + // Allow web-based clients to access Link header - required for mastodon pagination. + // https://stackoverflow.com/a/54928828 + // https://docs.joinmastodon.org/api/guidelines/#pagination + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers + reply.header('Access-Control-Expose-Headers', 'Link'); + + // Cache to avoid extra pre-flight requests + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age + reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds + + done(); + }); + } + + public addFlattenedQueryType(fastify: FastifyInstance) { + // Remove trailing "[]" from query params + fastify.addHook<{ Querystring?: Record }>('preValidation', (request, _reply, done) => { + if (!request.query || typeof(request.query) !== 'object') { + return done(); + } + + for (const key of Object.keys(request.query)) { + if (!key.endsWith('[]')) { + continue; + } + if (request.query[key] == null) { + continue; + } + + const newKey = key.substring(0, key.length - 2); + const newValue = request.query[key]; + const oldValue = request.query[newKey]; + + // Move the value to the correct key + if (oldValue != null) { + if (Array.isArray(oldValue)) { + // Works for both array and single values + request.query[newKey] = oldValue.concat(newValue); + } else if (Array.isArray(newValue)) { + // Preserve order + request.query[newKey] = [oldValue, ...newValue]; + } else { + // Preserve order + request.query[newKey] = [oldValue, newValue]; + } + } else { + request.query[newKey] = newValue; + } + + // Remove the invalid key + delete request.query[key]; + } + + return done(); + }); + } +} diff --git a/packages/backend/src/server/SkRateLimiterService.md b/packages/backend/src/server/SkRateLimiterService.md index c8a2b4e85c..55786664e1 100644 --- a/packages/backend/src/server/SkRateLimiterService.md +++ b/packages/backend/src/server/SkRateLimiterService.md @@ -34,13 +34,18 @@ Header meanings and usage have been devised by adapting common patterns to work ## Performance -SkRateLimiterService makes between 1 and 4 redis transactions per rate limit check. +SkRateLimiterService makes between 0 and 4 redis transactions per rate limit check. The first call is read-only, while the others perform at least one write operation. +No calls are made if a client has already been blocked at least once, as the block status is stored in a short-term memory cache. Two integer keys are stored per client/subject, and both expire together after the maximum duration of the limit. While performance has not been formally tested, it's expected that SkRateLimiterService has an impact roughly on par with the legacy RateLimiterService. Redis memory usage should be notably lower due to the reduced number of keys and avoidance of set / array constructions. If redis load does become a concern, then a dedicated node can be assigned via the `redisForRateLimit` config setting. +To prevent Redis DoS, SkRateLimiterService internally tracks the number of concurrent requests for each unique client/endpoint combination. +If the number of requests exceeds the limit's maximum value, then any further requests are automatically rejected. +The lockout will automatically end when the number of active requests drops to within the limit value. + ## Concurrency and Multi-Node Correctness To provide consistency across multi-node environments, leaky bucket is implemented with only atomic operations (`Increment`, `Decrement`, `Add`, and `Subtract`). @@ -54,6 +59,12 @@ Any possible conflict would have to occur within a few-milliseconds window, whic This error does not compound, as all further operations are relative (Increment and Add). Thus, it's considered an acceptable tradeoff given the limitations imposed by Redis and ioredis. +In-process memory caches are used sparingly to avoid consistency problems. +Besides the role factor cache, there is one "important" cache which directly impacts limit calculations: the lockout cache. +This cache stores response data for blocked limits, preventing repeated calls to redis if a client ignores the 429 errors and continues making requests. +Consistency is guaranteed by only caching blocked limits (allowances are not cached), and by limiting cached data to the duration of the block. +This ensures that stale limit info is never used. + ## Algorithm Pseudocode The Atomic Leaky Bucket algorithm is described here, in pseudocode: diff --git a/packages/backend/src/server/SkRateLimiterService.ts b/packages/backend/src/server/SkRateLimiterService.ts index 30bf092e4f..35e87b0fe8 100644 --- a/packages/backend/src/server/SkRateLimiterService.ts +++ b/packages/backend/src/server/SkRateLimiterService.ts @@ -17,10 +17,23 @@ import type { RoleService } from '@/core/RoleService.js'; // Required because MemoryKVCache doesn't support null keys. const defaultUserKey = ''; +interface ParsedLimit { + key: string; + now: number; + bucketSize: number; + dripRate: number; + dripSize: number; + fullResetMs: number; + fullResetSec: number; +} + @Injectable() export class SkRateLimiterService { // 1-minute cache interval private readonly factorCache = new MemoryKVCache(1000 * 60); + // 10-second cache interval + private readonly lockoutCache = new MemoryKVCache(1000 * 10); + private readonly requestCounts = new Map(); private readonly disabled: boolean; constructor( @@ -58,6 +71,8 @@ export class SkRateLimiterService { } const actor = typeof(actorOrUser) === 'object' ? actorOrUser.id : actorOrUser; + const actorKey = `@${actor}#${limit.key}`; + const userCacheKey = typeof(actorOrUser) === 'object' ? actorOrUser.id : defaultUserKey; const userRoleKey = typeof(actorOrUser) === 'object' ? actorOrUser.id : null; const factor = this.factorCache.get(userCacheKey) ?? await this.factorCache.fetch(userCacheKey, async () => { @@ -73,25 +88,81 @@ export class SkRateLimiterService { throw new Error(`Rate limit factor is zero or negative: ${factor}`); } - if (isLegacyRateLimit(limit)) { - return await this.limitLegacy(limit, actor, factor); - } else { - return await this.limitBucket(limit, actor, factor); - } - } - - private async limitLegacy(limit: Keyed, actor: string, factor: number): Promise { - if (hasMaxLimit(limit)) { - return await this.limitLegacyMinMax(limit, actor, factor); - } else if (hasMinLimit(limit)) { - return await this.limitLegacyMinOnly(limit, actor, factor); - } else { + const parsedLimit = this.parseLimit(limit, factor); + if (parsedLimit == null) { return disabledLimitInfo; } + + // Fast-path to avoid extra redis calls for blocked clients + const lockout = this.getLockout(actorKey, parsedLimit); + if (lockout) { + return lockout; + } + + // Fast-path to avoid queuing requests that are guaranteed to fail + const overflow = this.incrementOverflow(actorKey, parsedLimit); + if (overflow) { + return overflow; + } + + try { + const info = await this.limitBucket(parsedLimit, actor); + + // Store blocked status to avoid hammering redis + if (info.blocked) { + this.lockoutCache.set(actorKey, info.resetMs); + } + + return info; + } finally { + this.decrementOverflow(actorKey); + } } - private async limitLegacyMinMax(limit: Keyed, actor: string, factor: number): Promise { - if (limit.duration === 0) return disabledLimitInfo; + private getLockout(lockoutKey: string, limit: ParsedLimit): LimitInfo | null { + const lockoutReset = this.lockoutCache.get(lockoutKey); + if (!lockoutReset) { + // Not blocked, proceed with redis check + return null; + } + + if (limit.now >= lockoutReset) { + // Block expired, clear and proceed with redis check + this.lockoutCache.delete(lockoutKey); + return null; + } + + // Lockout is still active, pre-emptively reject the request + return { + blocked: true, + remaining: 0, + resetMs: limit.fullResetMs, + resetSec: limit.fullResetSec, + fullResetMs: limit.fullResetMs, + fullResetSec: limit.fullResetSec, + }; + } + + private parseLimit(limit: Keyed, factor: number): ParsedLimit | null { + if (isLegacyRateLimit(limit)) { + return this.parseLegacyLimit(limit, factor); + } else { + return this.parseBucketLimit(limit, factor); + } + } + + private parseLegacyLimit(limit: Keyed, factor: number): ParsedLimit | null { + if (hasMaxLimit(limit)) { + return this.parseLegacyMinMax(limit, factor); + } else if (hasMinLimit(limit)) { + return this.parseLegacyMinOnly(limit, factor); + } else { + return null; + } + } + + private parseLegacyMinMax(limit: Keyed, factor: number): ParsedLimit | null { + if (limit.duration === 0) return null; if (limit.duration < 0) throw new Error(`Invalid rate limit ${limit.key}: duration is negative (${limit.duration})`); if (limit.max < 1) throw new Error(`Invalid rate limit ${limit.key}: max is less than 1 (${limit.max})`); @@ -104,35 +175,30 @@ export class SkRateLimiterService { // Calculate final dripRate from dripSize and duration/max const dripRate = Math.max(Math.round(limit.duration / (limit.max / dripSize)), 1); - const bucketLimit: Keyed = { + return this.parseBucketLimit({ type: 'bucket', key: limit.key, size: limit.max, dripRate, dripSize, - }; - return await this.limitBucket(bucketLimit, actor, factor); + }, factor); } - private async limitLegacyMinOnly(limit: Keyed, actor: string, factor: number): Promise { - if (limit.minInterval === 0) return disabledLimitInfo; + private parseLegacyMinOnly(limit: Keyed, factor: number): ParsedLimit | null { + if (limit.minInterval === 0) return null; if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`); const dripRate = Math.max(Math.round(limit.minInterval), 1); - const bucketLimit: Keyed = { + return this.parseBucketLimit({ type: 'bucket', key: limit.key, size: 1, dripRate, dripSize: 1, - }; - return await this.limitBucket(bucketLimit, actor, factor); + }, factor); } - /** - * Implementation of Leaky Bucket rate limiting - see SkRateLimiterService.md for details. - */ - private async limitBucket(limit: Keyed, actor: string, factor: number): Promise { + private parseBucketLimit(limit: Keyed, factor: number): ParsedLimit { if (limit.size < 1) throw new Error(`Invalid rate limit ${limit.key}: size is less than 1 (${limit.size})`); if (limit.dripRate != null && limit.dripRate < 1) throw new Error(`Invalid rate limit ${limit.key}: dripRate is less than 1 (${limit.dripRate})`); if (limit.dripSize != null && limit.dripSize < 1) throw new Error(`Invalid rate limit ${limit.key}: dripSize is less than 1 (${limit.dripSize})`); @@ -142,7 +208,27 @@ export class SkRateLimiterService { const bucketSize = Math.max(Math.ceil(limit.size / factor), 1); const dripRate = Math.ceil(limit.dripRate ?? 1000); const dripSize = Math.ceil(limit.dripSize ?? 1); - const expirationSec = Math.max(Math.ceil((dripRate * Math.ceil(bucketSize / dripSize)) / 1000), 1); + const fullResetMs = dripRate * Math.ceil(bucketSize / dripSize); + const fullResetSec = Math.max(Math.ceil(fullResetMs / 1000), 1); + + return { + key: limit.key, + now, + bucketSize, + dripRate, + dripSize, + fullResetMs, + fullResetSec, + }; + } + + /** + * Implementation of Leaky Bucket rate limiting - see SkRateLimiterService.md for details. + */ + private async limitBucket(limit: ParsedLimit, actor: string): Promise { + // 0 - Calculate (extracted to other function) + const { now, bucketSize, dripRate, dripSize } = limit; + const expirationSec = limit.fullResetSec; // 1 - Read const counterKey = createLimitKey(limit, actor, 'c'); @@ -262,17 +348,48 @@ export class SkRateLimiterService { return responses; } + + private incrementOverflow(actorKey: string, limit: ParsedLimit): LimitInfo | null { + const oldCount = this.requestCounts.get(actorKey) ?? 0; + + if (oldCount >= limit.bucketSize) { + // Overflow, pre-emptively reject the request + return { + blocked: true, + remaining: 0, + resetMs: limit.fullResetMs, + resetSec: limit.fullResetSec, + fullResetMs: limit.fullResetMs, + fullResetSec: limit.fullResetSec, + }; + } + + // No overflow, increment and continue to redis + this.requestCounts.set(actorKey, oldCount + 1); + return null; + } + + private decrementOverflow(actorKey: string): void { + const count = this.requestCounts.get(actorKey); + if (count) { + if (count > 1) { + this.requestCounts.set(actorKey, count - 1); + } else { + this.requestCounts.delete(actorKey); + } + } + } } // Not correct, but good enough for the basic commands we use. type RedisResult = string | null; type RedisCommand = [command: string, ...args: unknown[]]; -function createLimitKey(limit: Keyed, actor: string, value: string): string { +function createLimitKey(limit: ParsedLimit, actor: string, value: string): string { return `rl_${actor}_${limit.key}_${value}`; } -class ConflictError extends Error {} +export class ConflictError extends Error {} interface LimitCounter { timestamp: number; diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index 70f672e5d9..f48310c50f 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -8,7 +8,7 @@ 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 { MiMeta, 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'; @@ -26,6 +26,9 @@ export class WellKnownServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -66,6 +69,11 @@ export class WellKnownServerService { }); fastify.get('/.well-known/host-meta', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + reply.header('Content-Type', xrd); return XRD({ element: 'Link', attributes: { rel: 'lrdd', @@ -75,6 +83,11 @@ export class WellKnownServerService { }); fastify.get('/.well-known/host-meta.json', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + reply.header('Content-Type', 'application/json'); return { links: [{ @@ -86,6 +99,11 @@ export class WellKnownServerService { }); fastify.get('/.well-known/nodeinfo', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + return { links: this.nodeinfoServerService.getLinks() }; }); @@ -99,6 +117,11 @@ fastify.get('/.well-known/change-password', async (request, reply) => { */ fastify.get<{ Querystring: { resource: string } }>(webFingerPath, async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const fromId = (id: MiUser['id']): FindOptionsWhere => ({ id, host: IsNull(), @@ -115,7 +138,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => { const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => !acct.host || acct.host === this.config.host.toLowerCase() ? { - usernameLower: acct.username, + usernameLower: acct.username.toLowerCase(), host: IsNull(), isSuspended: false, } : 422; diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 5ce358d68f..66d968224a 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -20,12 +20,14 @@ import { RoleService } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ApiError } from './error.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 { renderFullError } from '@/misc/render-full-error.js'; const accessDenied = { message: 'Access denied.', @@ -100,26 +102,26 @@ export class ApiCallService implements OnApplicationShutdown { 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, - }, + const fullError = renderFullError(err); + const message = typeof(fullError) === 'string' + ? `Internal error id=${errId} occurred in ${ep.name}: ${fullError}` + : `Internal error id=${errId} occurred in ${ep.name}:`; + const data = typeof(fullError) === 'object' + ? { e: fullError } + : {}; + this.logger.error(message, { + user: userId ?? '', + ...data, }); if (this.config.sentryForBackend) { - Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${renderInlineError(err)}`, { level: 'error', user: { id: userId, }, extra: { ep: ep.name, - ps: data, e: { message: err.message, code: err.name, @@ -146,6 +148,10 @@ export class ApiCallService implements OnApplicationShutdown { request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, reply: FastifyReply, ): void { + // Tell crawlers not to index API endpoints. + // https://developers.google.com/search/docs/crawling-indexing/block-indexing + reply.header('X-Robots-Tag', 'noindex'); + const body = request.method === 'GET' ? request.query : request.body; @@ -213,6 +219,7 @@ export class ApiCallService implements OnApplicationShutdown { ? request.headers.authorization.slice(7) : fields['i']; if (token != null && typeof token !== 'string') { + cleanup(); reply.code(400); return; } @@ -223,6 +230,7 @@ export class ApiCallService implements OnApplicationShutdown { }, request, reply).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { + cleanup(); this.#sendApiError(reply, err); }); @@ -230,6 +238,7 @@ export class ApiCallService implements OnApplicationShutdown { this.logIp(request, user); } }).catch(err => { + cleanup(); this.#sendAuthenticationError(reply, err); }); } @@ -341,14 +350,14 @@ export class ApiCallService implements OnApplicationShutdown { } if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) { - if (user == null) { + if (user == null && ep.meta.requireCredential !== 'optional') { throw new ApiError({ message: 'Credential required.', code: 'CREDENTIAL_REQUIRED', id: '1384574d-a912-4b81-8601-c7b1c4085df1', httpStatusCode: 401, }); - } else if (user!.isSuspended) { + } else if (user?.isSuspended) { throw new ApiError({ message: 'Your account has been suspended.', code: 'YOUR_ACCOUNT_SUSPENDED', @@ -369,8 +378,8 @@ export class ApiCallService implements OnApplicationShutdown { } } - if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) { - const myRoles = await this.roleService.getUserRoles(user!.id); + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user?.id)) { + const myRoles = user ? await this.roleService.getUserRoles(user) : []; if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to a moderator role.', @@ -389,10 +398,10 @@ export class ApiCallService implements OnApplicationShutdown { } } - 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)) { + if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user?.id)) { + const myRoles = user ? await this.roleService.getUserRoles(user) : []; + const policies = await this.roleService.getUserPolicies(user ?? null); + if (!policies[ep.meta.requiredRolePolicy] && !myRoles.some(r => r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to a required role.', code: 'ROLE_PERMISSION_DENIED', @@ -415,7 +424,7 @@ export class ApiCallService implements OnApplicationShutdown { // 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]; + const param = ep.params.properties[k]; if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { try { data[k] = JSON.parse(data[k]); diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 0d77309537..12459d5698 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -6,7 +6,6 @@ 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 { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { Config } from '@/config.js'; @@ -57,8 +56,6 @@ export class ApiServerService { }, }); - fastify.register(fastifyCookie, {}); - // Prevent cache fastify.addHook('onRequest', (request, reply, done) => { reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 690ff2e022..397626c49d 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -11,7 +11,7 @@ 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 { isNativeUserToken } from '@/misc/token.js'; import { bindThis } from '@/decorators.js'; export class AuthenticationError extends Error { @@ -46,7 +46,7 @@ export class AuthenticateService implements OnApplicationShutdown { return [null, null]; } - if (isNativeToken(token)) { + if (isNativeUserToken(token)) { const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); @@ -84,6 +84,8 @@ export class AuthenticateService implements OnApplicationShutdown { return [user, { id: accessToken.id, permission: app.permission, + appId: app.id, + app, } as MiAccessToken]; } else { return [user, accessToken]; diff --git a/packages/backend/src/server/api/GetterService.ts b/packages/backend/src/server/api/GetterService.ts index 419017aaf4..f2850e6258 100644 --- a/packages/backend/src/server/api/GetterService.ts +++ b/packages/backend/src/server/api/GetterService.ts @@ -36,7 +36,7 @@ export class GetterService { const note = await this.notesRepository.findOneBy({ id: noteId }); if (note == null) { - throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', `Note ${noteId} does not exist`); } return note; @@ -47,7 +47,7 @@ export class GetterService { const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user'] }); if (note == null) { - throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', `Note ${noteId} does not exist`); } return note; @@ -59,7 +59,7 @@ export class GetterService { @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.'); + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', `Note ${noteId} does not exist`); }); return edits; @@ -73,7 +73,7 @@ export class GetterService { const user = await this.usersRepository.findOneBy({ id: userId }); if (user == null) { - throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); + throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', `User ${userId} does not exist`); } return user as MiLocalUser | MiRemoteUser; diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts deleted file mode 100644 index 879529090f..0000000000 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * 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 { LegacyRateLimit } from '@/misc/rate-limit-utils.js'; -import type { IEndpointMeta } from './endpoints.js'; - -/** @deprecated Use SkRateLimiterService instead */ -@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: LegacyRateLimit & { key: NonNullable }, actor: string, factor = 1) { - return new Promise((ok, reject) => { - if (this.disabled) ok(); - - // Short-term limit - const minP = (): 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({ code: 'ERR', info }); - } - - this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); - - if (info.remaining === 0) { - return reject({ code: 'BRIEF_REQUEST_INTERVAL', info }); - } else { - if (hasLongTermLimit) { - return maxP(); - } else { - return ok(); - } - } - }); - }; - - // Long term limit - const maxP = (): 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({ code: 'ERR', info }); - } - - this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); - - if (info.remaining === 0) { - return reject({ code: 'RATE_LIMIT_EXCEEDED', info }); - } else { - return ok(); - } - }); - }; - - const hasShortTermLimit = typeof limitation.minInterval === 'number'; - - const hasLongTermLimit = - typeof limitation.duration === 'number' && - typeof limitation.max === 'number'; - - if (hasShortTermLimit) { - minP(); - } else if (hasLongTermLimit) { - maxP(); - } else { - ok(); - } - }); - } -} diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 72712bce60..a53fec88d0 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -35,7 +35,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; // Up to 10 attempts, then 1 per minute const signinRateLimit: Keyed = { key: 'signin', - max: 10, + type: 'bucket', + size: 10, dripRate: 1000 * 60, }; @@ -146,7 +147,7 @@ export class SigninApiService { if (isSystemAccount(user)) { return error(403, { - id: 's8dhsj9s-a93j-493j-ja9k-kas9sj20aml2', + id: 'ba4ba3bc-ef1e-4c74-ad88-1d2b7d69a100', }); } @@ -204,37 +205,37 @@ export class SigninApiService { if (process.env.NODE_ENV !== 'test') { if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableFC && this.meta.fcSecretKey) { await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableTestcaptcha) { await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } } @@ -243,7 +244,7 @@ export class SigninApiService { if (profile.password!.startsWith('$2')) { const newHash = await argon2.hash(password); this.userProfilesRepository.update(user.id, { - password: newHash + password: newHash, }); } if (!this.meta.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true }); @@ -267,7 +268,7 @@ export class SigninApiService { if (profile.password!.startsWith('$2')) { const newHash = await argon2.hash(password); this.userProfilesRepository.update(user.id, { - password: newHash + password: newHash, }); } await this.userAuthService.twoFactorAuthenticate(profile, token); diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index f84f50523b..38886f8876 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -128,7 +128,7 @@ export class SigninWithPasskeyApiService { try { authorizedUserId = await this.webAuthnService.verifySignInWithPasskeyAuthentication(context, credential); } catch (err) { - this.logger.warn(`Passkey challenge Verify error! : ${err}`); + this.logger.warn('Passkey challenge verify error:', err as Error); const errorId = (err as IdentifiableError).id; return error(403, { id: errorId, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 42137d3298..81e3a5b706 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -4,7 +4,6 @@ */ 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'; @@ -84,37 +83,37 @@ export class SignupApiService { if (process.env.NODE_ENV !== 'test') { if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableFC && this.meta.fcSecretKey) { await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } if (this.meta.enableTestcaptcha) { await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new FastifyReplyError(400, String(err), err); }); } } @@ -205,7 +204,6 @@ export class SignupApiService { 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.insertOne({ @@ -289,7 +287,7 @@ export class SignupApiService { token: secret, }; } catch (err) { - throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); + throw new FastifyReplyError(400, String(err), err); } } } @@ -358,7 +356,7 @@ export class SignupApiService { return this.signinService.signin(request, reply, account as MiLocalUser); } catch (err) { - throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); + throw new FastifyReplyError(400, String(err), err); } } } diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 6e7abcfae6..eaeaecb1c2 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -10,8 +10,9 @@ import * as WebSocket from 'ws'; import proxyAddr from 'proxy-addr'; import ms from 'ms'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, MiAccessToken } from '@/models/_.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; +import type { UsersRepository, MiAccessToken, MiUser } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import type { Keyed, RateLimit } from '@/misc/rate-limit-utils.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; @@ -25,12 +26,16 @@ import { AuthenticateService, AuthenticationError } from './AuthenticateService. import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; import type * as http from 'node:http'; -import type { IEndpointMeta } from './endpoints.js'; + +// Maximum number of simultaneous connections by client (user ID or IP address). +// Excess connections will be closed automatically. +const MAX_CONNECTIONS_PER_CLIENT = 32; @Injectable() export class StreamingApiServerService { #wss: WebSocket.WebSocketServer; #connections = new Map(); + #connectionsByClient = new Map>(); // key: IP / user ID -> value: connection #cleanConnectionsIntervalId: NodeJS.Timeout | null = null; constructor( @@ -41,7 +46,6 @@ export class StreamingApiServerService { private usersRepository: UsersRepository, private cacheService: CacheService, - private noteReadService: NoteReadService, private authenticateService: AuthenticateService, private channelsService: ChannelsService, private notificationService: NotificationService, @@ -49,22 +53,17 @@ export class StreamingApiServerService { private channelFollowingService: ChannelFollowingService, private rateLimiterService: SkRateLimiterService, private loggerService: LoggerService, + + @Inject(DI.config) + private config: Config, ) { } @bindThis private async rateLimitThis( - user: MiLocalUser | null | undefined, - requestIp: string, - limit: IEndpointMeta['limit'] & { key: NonNullable }, + limitActor: MiUser | string, + limit: Keyed, ) : Promise { - let limitActor: string | MiLocalUser; - if (user) { - limitActor = user; - } else { - limitActor = getIpHash(requestIp); - } - // Rate limit const rateLimit = await this.rateLimiterService.limit(limit, limitActor); return rateLimit.blocked; @@ -74,6 +73,7 @@ export class StreamingApiServerService { public attach(server: http.Server): void { this.#wss = new WebSocket.WebSocketServer({ noServer: true, + perMessageDeflate: this.config.websocketCompression, }); server.on('upgrade', async (request, socket, head) => { @@ -83,21 +83,6 @@ export class StreamingApiServerService { return; } - // ServerServices sets `trustProxy: true`, which inside - // fastify/request.js ends up calling `proxyAddr` in this way, - // so we do the same - const requestIp = proxyAddr(request, () => { return true; } ); - - if (await this.rateLimitThis(null, requestIp, { - key: 'wsconnect', - duration: ms('5min'), - max: 32, - })) { - socket.write('HTTP/1.1 429 Rate Limit Exceeded\r\n\r\n'); - socket.destroy(); - return; - } - const q = new URL(request.url, `http://${request.headers.host}`).searchParams; let user: MiLocalUser | null = null; @@ -135,21 +120,55 @@ export class StreamingApiServerService { return; } + // ServerServices sets `trustProxy: true`, which inside fastify/request.js ends up calling `proxyAddr` in this way, so we do the same. + const requestIp = proxyAddr(request, () => true ); + const limitActor = user?.id ?? getIpHash(requestIp); + if (await this.rateLimitThis(limitActor, { + // Up to 32 connections, then 1 every 10 seconds + type: 'bucket', + key: 'wsconnect', + size: 32, + dripRate: 10 * 1000, + })) { + socket.write('HTTP/1.1 429 Rate Limit Exceeded\r\n\r\n'); + socket.destroy(); + return; + } + + // For performance and code simplicity, obtain and hold this reference for the lifetime of the connection. + // This should be safe because the map entry should only be deleted after *all* connections close. + let connectionsForClient = this.#connectionsByClient.get(limitActor); + if (!connectionsForClient) { + connectionsForClient = new Set(); + this.#connectionsByClient.set(limitActor, connectionsForClient); + } + + // Close excess connections + while (connectionsForClient.size >= MAX_CONNECTIONS_PER_CLIENT) { + // Set maintains insertion order, so first entry is the oldest. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const oldestConnection = connectionsForClient.values().next().value!; + + // Technically, the close() handler should remove this entry. + // But if that ever fails, then we could enter an infinite loop. + // We manually remove the connection here just in case. + oldestConnection.close(1008, 'Disconnected - too many simultaneous connections'); + connectionsForClient.delete(oldestConnection); + } + const rateLimiter = () => { - // rather high limit, because when catching up at the top of a - // timeline, the frontend may render many many notes, each of - // which causes a message via `useNoteCapture` to ask for - // realtime updates of that note - return this.rateLimitThis(user, requestIp, { + // Rather high limit because when catching up at the top of a timeline, the frontend may render many many notes. + // Each of which causes a message via `useNoteCapture` to ask for realtime updates of that note. + return this.rateLimitThis(limitActor, { + type: 'bucket', key: 'wsmessage', - duration: ms('2sec'), - max: 4096, + size: 4096, // Allow spikes of up to 4096 + dripRate: 50, // Then once every 50ms (20/second rate) }); }; const stream = new MainStreamConnection( this.channelsService, - this.noteReadService, this.notificationService, this.cacheService, this.channelFollowingService, @@ -161,6 +180,19 @@ export class StreamingApiServerService { await stream.init(); this.#wss.handleUpgrade(request, socket, head, (ws) => { + connectionsForClient.add(ws); + + // Call before emit() in case it throws an error. + // We don't want to leave dangling references! + ws.once('close', () => { + connectionsForClient.delete(ws); + + // Make sure we don't leak the Set objects! + if (connectionsForClient.size < 1) { + this.#connectionsByClient.delete(limitActor); + } + }); + this.#wss.emit('connection', ws, request, { stream, user, app, }); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index a641a14448..a78c3e9ae6 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -72,8 +72,14 @@ export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js'; export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js'; -export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js'; +export * as 'admin/queue/retry-job' from './endpoints/admin/queue/retry-job.js'; +export * as 'admin/queue/remove-job' from './endpoints/admin/queue/remove-job.js'; +export * as 'admin/queue/show-job' from './endpoints/admin/queue/show-job.js'; +export * as 'admin/queue/promote-jobs' from './endpoints/admin/queue/promote-jobs.js'; +export * as 'admin/queue/jobs' from './endpoints/admin/queue/jobs.js'; export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js'; +export * as 'admin/queue/queues' from './endpoints/admin/queue/queues.js'; +export * as 'admin/queue/queue-stats' from './endpoints/admin/queue/queue-stats.js'; export * as 'admin/reject-quotes' from './endpoints/admin/reject-quotes.js'; export * as 'admin/relays/add' from './endpoints/admin/relays/add.js'; export * as 'admin/relays/list' from './endpoints/admin/relays/list.js'; @@ -109,6 +115,7 @@ export * as 'admin/unsilence-user' from './endpoints/admin/unsilence-user.js'; export * as 'admin/unsuspend-user' from './endpoints/admin/unsuspend-user.js'; export * as 'admin/update-abuse-user-report' from './endpoints/admin/update-abuse-user-report.js'; export * as 'admin/update-meta' from './endpoints/admin/update-meta.js'; +export * as 'admin/update-proxy-account' from './endpoints/admin/update-proxy-account.js'; export * as 'admin/update-user-note' from './endpoints/admin/update-user-note.js'; export * as 'announcements' from './endpoints/announcements.js'; export * as 'announcements/show' from './endpoints/announcements/show.js'; @@ -121,6 +128,7 @@ export * as 'antennas/update' from './endpoints/antennas/update.js'; export * as 'ap/get' from './endpoints/ap/get.js'; export * as 'ap/show' from './endpoints/ap/show.js'; export * as 'app/create' from './endpoints/app/create.js'; +export * as 'app/current' from './endpoints/app/current.js'; export * as 'app/show' from './endpoints/app/show.js'; export * as 'auth/accept' from './endpoints/auth/accept.js'; export * as 'auth/session/generate' from './endpoints/auth/session/generate.js'; @@ -273,7 +281,6 @@ export * as 'i/notifications-grouped' from './endpoints/i/notifications-grouped. export * as 'i/page-likes' from './endpoints/i/page-likes.js'; export * as 'i/pages' from './endpoints/i/pages.js'; export * as 'i/pin' from './endpoints/i/pin.js'; -export * as 'i/read-all-unread-notes' from './endpoints/i/read-all-unread-notes.js'; export * as 'i/read-announcement' from './endpoints/i/read-announcement.js'; export * as 'i/regenerate-token' from './endpoints/i/regenerate-token.js'; export * as 'i/registry/get' from './endpoints/i/registry/get.js'; @@ -418,4 +425,28 @@ export * as 'users/search' from './endpoints/users/search.js'; export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js'; export * as 'users/show' from './endpoints/users/show.js'; export * as 'users/update-memo' from './endpoints/users/update-memo.js'; +export * as 'chat/messages/create-to-user' from './endpoints/chat/messages/create-to-user.js'; +export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/create-to-room.js'; +export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js'; +export * as 'chat/messages/show' from './endpoints/chat/messages/show.js'; +export * as 'chat/messages/react' from './endpoints/chat/messages/react.js'; +export * as 'chat/messages/unreact' from './endpoints/chat/messages/unreact.js'; +export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js'; +export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js'; +export * as 'chat/messages/search' from './endpoints/chat/messages/search.js'; +export * as 'chat/rooms/create' from './endpoints/chat/rooms/create.js'; +export * as 'chat/rooms/delete' from './endpoints/chat/rooms/delete.js'; +export * as 'chat/rooms/join' from './endpoints/chat/rooms/join.js'; +export * as 'chat/rooms/leave' from './endpoints/chat/rooms/leave.js'; +export * as 'chat/rooms/mute' from './endpoints/chat/rooms/mute.js'; +export * as 'chat/rooms/show' from './endpoints/chat/rooms/show.js'; +export * as 'chat/rooms/owned' from './endpoints/chat/rooms/owned.js'; +export * as 'chat/rooms/joining' from './endpoints/chat/rooms/joining.js'; +export * as 'chat/rooms/update' from './endpoints/chat/rooms/update.js'; +export * as 'chat/rooms/members' from './endpoints/chat/rooms/members.js'; +export * as 'chat/rooms/invitations/create' from './endpoints/chat/rooms/invitations/create.js'; +export * as 'chat/rooms/invitations/ignore' from './endpoints/chat/rooms/invitations/ignore.js'; +export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js'; +export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js'; +export * as 'chat/history' from './endpoints/chat/history.js'; export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index fd6b9bb14b..c7d884cce1 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -40,7 +40,7 @@ interface IEndpointMetaBase { */ readonly requireAdmin?: boolean; - readonly requireRolePolicy?: KeyOf<'RolePolicies'>; + readonly requiredRolePolicy?: KeyOf<'RolePolicies'>; /** * 引っ越し済みのユーザーによるリクエストを禁止するか @@ -92,7 +92,7 @@ export type IEndpointMeta = (Omit & { secure: true, }) | (Omit & { - requireCredential: true, + requireCredential: true | 'optional', kind: (typeof permissions)[number], }) | (Omit & { requireModerator: true, @@ -100,7 +100,7 @@ export type IEndpointMeta = (Omit & { requireAdmin: true, kind: (typeof permissions)[number], -}) +}); export interface IEndpoint { name: string; 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 0dbfaae054..b8200c09aa 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 @@ -69,6 +69,11 @@ export const meta = { nullable: false, optional: false, ref: 'UserDetailedNotMe', }, + targetInstance: { + type: 'object', + nullable: true, optional: false, + ref: 'FederationInstance', + }, assignee: { type: 'object', nullable: true, optional: false, @@ -115,7 +120,15 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId) + .leftJoinAndSelect('report.targetUser', 'targetUser') + .leftJoinAndSelect('targetUser.userProfile', 'targetUserProfile') + .leftJoinAndSelect('report.targetUserInstance', 'targetUserInstance') + .leftJoinAndSelect('report.reporter', 'reporter') + .leftJoinAndSelect('reporter.userProfile', 'reporterProfile') + .leftJoinAndSelect('report.assignee', 'assignee') + .leftJoinAndSelect('assignee.userProfile', 'assigneeProfile') + ; switch (ps.state) { case 'resolved': query.andWhere('report.resolved = TRUE'); break; @@ -134,7 +147,7 @@ export default class extends Endpoint { // eslint- const reports = await query.limit(ps.limit).getMany(); - return await this.abuseUserReportEntityService.packMany(reports); + return await this.abuseUserReportEntityService.packMany(reports, me); }); } } 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 1a47f56bc6..88490800cf 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -5,10 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; import { SignupService } from '@/core/SignupService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; import { localUsernameSchema, passwordSchema } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -90,20 +89,21 @@ export default class extends Endpoint { // eslint- @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, private roleService: RoleService, private userEntityService: UserEntityService, private signupService: SignupService, - private instanceActorService: InstanceActorService, private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, _me, token) => { const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; - const realUsers = await this.instanceActorService.realLocalUsersPresent(); - if (!realUsers && me == null && token == null) { + if (this.serverSettings.rootUserId == null && me == null && token == null) { // 初回セットアップの場合 if (this.config.setupPassword != null) { // 初期パスワードが設定されている場合 @@ -127,7 +127,7 @@ export default class extends Endpoint { // eslint- } // Anonymous access is only allowed for initial instance setup (this check may be redundant) - if (!me && realUsers) { + if (!me && this.serverSettings.rootUserId != null) { throw new ApiError(meta.errors.noCredential); } } 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 ece1984cff..d04f52dd64 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -42,10 +42,6 @@ export default class extends Endpoint { // eslint- throw new Error('user not found'); } - if (user.isRoot) { - throw new Error('cannot delete a root account'); - } - await this.deleteAccoountService.deleteAccount(user, me); }); } 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 index 87d80cbe80..0121c302ac 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', res: { 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 index 3a5673d99d..13660d0b8c 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', errors: { }, 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 index d785f085ac..d4d9a7235b 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'read:admin:avatar-decorations', res: { 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 index 34b3b5a11f..22476a6888 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', errors: { 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 795b579041..56db393996 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 @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; 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 703ce1e7e8..b773aade6f 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { 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 bf90bfaa14..a61abc45af 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -17,7 +17,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { 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 cec9f700c3..7993edcc07 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 @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; 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 50c45b6ac5..87ed3f5f18 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { 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 ee7706f31a..921ecacaf3 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 @@ -13,7 +13,7 @@ import { DI } from '@/di-symbols.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', } as const; export const paramDef = { @@ -33,7 +33,7 @@ export default class extends Endpoint { // eslint- private readonly driveFilesRepository: DriveFilesRepository, ) { super(meta, paramDef, async (ps, me) => { - const file = await driveFilesRepository.findOneByOrFail({ id: ps.fileId }); + const file = await this.driveFilesRepository.findOneByOrFail({ id: ps.fileId }); await this.moderationLogService.log(me, 'importCustomEmojis', { fileName: file.name, }); 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 1182918ea2..7982c1f0bd 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 @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'read:admin:emoji', res: { 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 fbc05dabd0..afe067e5d4 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'read:admin:emoji', res: { 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 066eb1c7d9..2d8867b9fd 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 @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; 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 8980ef0c86..8086af8ed5 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 @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; 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 2510349210..5d3b39d7da 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 @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; 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 index a0205ae24a..4b916508a7 100644 --- 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 @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; 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 32a2d0a170..6b4610166f 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -14,7 +14,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts index 5695866265..85e3cd0477 100644 --- a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts +++ b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts @@ -26,7 +26,9 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const keys = await generateVAPIDKeys(); - + + // TODO add moderation log + return { public: keys.publicKey, private: keys.privateKey }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 436dcf27cb..fe8ca012b2 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -9,6 +9,8 @@ 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 { instanceUnsignedFetchOptions } from '@/const.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; export const meta = { tags: ['meta'], @@ -264,7 +266,7 @@ export const meta = { }, proxyAccountId: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, format: 'id', }, email: { @@ -443,6 +445,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + translationTimeout: { + type: 'number', + optional: false, nullable: false, + }, deeplAuthKey: { type: 'string', optional: false, nullable: true, @@ -459,6 +465,14 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + libreTranslateURL: { + type: 'string', + optional: false, nullable: true, + }, + libreTranslateKey: { + type: 'string', + optional: false, nullable: true, + }, defaultDarkTheme: { type: 'string', optional: false, nullable: true, @@ -467,6 +481,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + defaultLike: { + type: 'string', + optional: false, nullable: false, + }, description: { type: 'string', optional: false, nullable: true, @@ -571,6 +589,7 @@ export const meta = { }, federation: { type: 'string', + enum: ['all', 'specified', 'none'], optional: false, nullable: false, }, federationHosts: { @@ -581,6 +600,19 @@ export const meta = { optional: false, nullable: false, }, }, + hasLegacyAuthFetchSetting: { + type: 'boolean', + optional: false, nullable: false, + }, + allowUnsignedFetch: { + type: 'string', + enum: instanceUnsignedFetchOptions, + optional: false, nullable: false, + }, + enableProxyAccount: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, } as const; @@ -599,10 +631,13 @@ export default class extends Endpoint { // eslint- private config: Config, private metaService: MetaService, + private systemAccountService: SystemAccountService, ) { super(meta, paramDef, async () => { const instance = await this.metaService.fetch(true); + const proxy = await this.systemAccountService.fetch('proxy'); + return { maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, @@ -652,7 +687,7 @@ export default class extends Endpoint { // eslint- defaultLike: instance.defaultLike, enableEmail: instance.enableEmail, enableServiceWorker: instance.enableServiceWorker, - translatorAvailable: instance.deeplAuthKey != null, + translatorAvailable: instance.deeplAuthKey != null || instance.libreTranslateURL != null || instance.deeplFreeMode && instance.deeplFreeInstance != null, cacheRemoteFiles: instance.cacheRemoteFiles, cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, pinnedUsers: instance.pinnedUsers, @@ -675,7 +710,7 @@ export default class extends Endpoint { // eslint- setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, enableBotTrending: instance.enableBotTrending, - proxyAccountId: instance.proxyAccountId, + proxyAccountId: proxy.id, email: instance.email, smtpSecure: instance.smtpSecure, smtpHost: instance.smtpHost, @@ -696,10 +731,13 @@ export default class extends Endpoint { // eslint- objectStorageUseProxy: instance.objectStorageUseProxy, objectStorageSetPublicRead: instance.objectStorageSetPublicRead, objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, + translationTimeout: instance.translationTimeout, deeplAuthKey: instance.deeplAuthKey, deeplIsPro: instance.deeplIsPro, deeplFreeMode: instance.deeplFreeMode, deeplFreeInstance: instance.deeplFreeInstance, + libreTranslateURL: instance.libreTranslateURL, + libreTranslateKey: instance.libreTranslateKey, enableIpLogging: instance.enableIpLogging, enableActiveEmailValidation: instance.enableActiveEmailValidation, enableVerifymailApi: instance.enableVerifymailApi, @@ -735,6 +773,9 @@ export default class extends Endpoint { // eslint- trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns, federation: instance.federation, federationHosts: instance.federationHosts, + hasLegacyAuthFetchSetting: config.checkActivityPubGetSignature != null, + allowUnsignedFetch: instance.allowUnsignedFetch, + enableProxyAccount: instance.enableProxyAccount, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts index 194e793eda..f6c4f0b635 100644 --- a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { // eslint- alwaysMarkNsfw: true, }); - await this.cacheService.userProfileCache.refresh(ps.userId); + await this.cacheService.userProfileCache.delete(ps.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 3f7df0e63d..81cb4b8119 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -6,7 +6,7 @@ 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 { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -18,8 +18,11 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, - required: [], + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + state: { type: 'string', enum: ['*', 'completed', 'wait', 'active', 'paused', 'prioritized', 'delayed', 'failed'] }, + }, + required: ['queue', 'state'], } as const; @Injectable() @@ -29,7 +32,7 @@ export default class extends Endpoint { // eslint- private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { - this.queueService.destroy(); + this.queueService.queueClear(ps.queue, ps.state); this.moderationLogService.log(me, 'clearQueue'); }); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts new file mode 100644 index 0000000000..aba68376ad --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + state: { type: 'array', items: { type: 'string', enum: ['active', 'paused', 'wait', 'delayed', 'completed', 'failed'] } }, + search: { type: 'string' }, + }, + required: ['queue', 'state'], +} 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) => { + return this.queueService.queueGetJobs(ps.queue, ps.state, ps.search); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts new file mode 100644 index 0000000000..d22385e261 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + }, + required: ['queue'], +} 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.queuePromoteJobs(ps.queue); + + this.moderationLogService.log(me, 'promoteQueue'); + }); + } +} 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 7502d4e1f7..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * 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: true, - 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/queue-stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts new file mode 100644 index 0000000000..10ce48332a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + }, + required: ['queue'], +} 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) => { + return this.queueService.queueGetQueue(ps.queue); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queues.ts b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts new file mode 100644 index 0000000000..3a38275f60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} 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) => { + return this.queueService.queueGetQueues(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts new file mode 100644 index 0000000000..2c73f689d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} 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.queueRemoveJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts new file mode 100644 index 0000000000..b2603128f8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} 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.queueRetryJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts new file mode 100644 index 0000000000..63747b5540 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} 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) => { + return this.queueService.queueGetJob(ps.queue, ps.jobId); + }); + } +} 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 129f69aca9..4644a069ee 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -68,11 +68,8 @@ export default class extends Endpoint { // eslint- private readonly moderationLogService: ModerationLogService, ) { 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); - } + if (!URL.canParse(ps.inbox)) throw new ApiError(meta.errors.invalidUrl); + if (new URL(ps.inbox).protocol !== 'https:') throw new ApiError(meta.errors.invalidUrl); await this.moderationLogService.log(me, 'addRelay', { inbox: 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 e4bb545f5d..57b7170052 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -4,10 +4,9 @@ */ 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 type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -45,6 +44,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -60,7 +62,7 @@ export default class extends Endpoint { // eslint- throw new Error('user not found'); } - if (user.isRoot) { + if (this.serverSettings.rootUserId === user.id) { throw new Error('cannot reset password of root'); } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index e0c02f7a5d..f92f7ebaeb 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -36,6 +36,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility asBadge: { type: 'boolean' }, + preserveAssignmentOnMoveAccount: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, 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 index 465ad7aaaf..175adcb63f 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -41,6 +41,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean' }, asBadge: { type: 'boolean' }, + preserveAssignmentOnMoveAccount: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, policies: { @@ -78,6 +79,7 @@ export default class extends Endpoint { // eslint- isAdministrator: ps.isAdministrator, isExplorable: ps.isExplorable, asBadge: ps.asBadge, + preserveAssignmentOnMoveAccount: ps.preserveAssignmentOnMoveAccount, canEditMembersByModerator: ps.canEditMembersByModerator, displayOrder: ps.displayOrder, policies: ps.policies, 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 669bffe2dc..6f0081f1f7 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -12,6 +12,7 @@ import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; import { IdService } from '@/core/IdService.js'; import { notificationRecieveConfig } from '@/models/json-schema/user.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['admin'], @@ -111,6 +112,7 @@ export const meta = { receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig }, + chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig }, achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, @@ -120,6 +122,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isAdministrator: { + type: 'boolean', + optional: false, nullable: false, + }, isSystem: { type: 'boolean', optional: false, nullable: false, @@ -185,6 +191,40 @@ export const meta = { }, }, }, + followStats: { + type: 'object', + optional: false, nullable: false, + properties: { + totalFollowing: { + type: 'number', + optional: false, nullable: false, + }, + totalFollowers: { + type: 'number', + optional: false, nullable: false, + }, + localFollowing: { + type: 'number', + optional: false, nullable: false, + }, + localFollowers: { + type: 'number', + optional: false, nullable: false, + }, + remoteFollowing: { + type: 'number', + optional: false, nullable: false, + }, + remoteFollowers: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, + signupReason: { + type: 'string', + optional: false, nullable: true, + }, }, }, } as const; @@ -212,6 +252,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, private roleEntityService: RoleEntityService, private idService: IdService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const [user, profile] = await Promise.all([ @@ -224,6 +265,7 @@ export default class extends Endpoint { // eslint- } const isModerator = await this.roleService.isModerator(user); + const isAdministrator = await this.roleService.isAdministrator(user); const isSilenced = user.isSilenced || !(await this.roleService.getUserPolicies(user.id)).canPublicNote; const _me = await this.usersRepository.findOneByOrFail({ id: me.id }); @@ -236,6 +278,8 @@ export default class extends Endpoint { // eslint- const roleAssigns = await this.roleService.getUserAssigns(user.id); const roles = await this.roleService.getUserRoles(user.id); + const followStats = await this.cacheService.getFollowStats(user.id); + return { email: profile.email, emailVerified: profile.emailVerified, @@ -254,6 +298,7 @@ export default class extends Endpoint { // eslint- mutedInstances: profile.mutedInstances, notificationRecieveConfig: profile.notificationRecieveConfig, isModerator: isModerator, + isAdministrator: isAdministrator, isSystem: isSystemAccount(user), isSilenced: isSilenced, isSuspended: user.isSuspended, @@ -268,6 +313,11 @@ export default class extends Endpoint { // eslint- expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null, roleId: a.roleId, })), + followStats: { + ...followStats, + totalFollowers: Math.max(user.followersCount, followStats.localFollowers + followStats.remoteFollowers), + totalFollowing: Math.max(user.followingCount, followStats.localFollowing + followStats.remoteFollowing), + }, }; }); } 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 b3733d3d39..4970d28cfa 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -8,6 +8,7 @@ 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 { instanceUnsignedFetchOptions } from '@/const.js'; export const meta = { tags: ['admin'], @@ -68,7 +69,7 @@ export const paramDef = { description: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, - defaultLike: { type: 'string', nullable: true }, + defaultLike: { type: 'string' }, cacheRemoteFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, @@ -95,7 +96,6 @@ export const paramDef = { 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: { @@ -103,10 +103,13 @@ export const paramDef = { type: 'string', }, }, + translationTimeout: { type: 'number' }, deeplAuthKey: { type: 'string', nullable: true }, deeplIsPro: { type: 'boolean' }, deeplFreeMode: { type: 'boolean' }, deeplFreeInstance: { type: 'string', nullable: true }, + libreTranslateURL: { type: 'string', nullable: true }, + libreTranslateKey: { type: 'string', nullable: true }, enableEmail: { type: 'boolean' }, email: { type: 'string', nullable: true }, smtpSecure: { type: 'boolean' }, @@ -127,7 +130,7 @@ export const paramDef = { useObjectStorage: { type: 'boolean' }, objectStorageBaseUrl: { type: 'string', nullable: true }, objectStorageBucket: { type: 'string', nullable: true }, - objectStoragePrefix: { type: 'string', nullable: true }, + objectStoragePrefix: { type: 'string', pattern: /^[a-zA-Z0-9-._\/]*$/.source, nullable: true }, objectStorageEndpoint: { type: 'string', nullable: true }, objectStorageRegion: { type: 'string', nullable: true }, objectStoragePort: { type: 'integer', nullable: true }, @@ -203,6 +206,15 @@ export const paramDef = { type: 'string', }, }, + allowUnsignedFetch: { + type: 'string', + enum: instanceUnsignedFetchOptions, + nullable: false, + }, + enableProxyAccount: { + type: 'boolean', + nullable: false, + }, }, required: [], } as const; @@ -397,14 +409,14 @@ export default class extends Endpoint { // eslint- set.turnstileSecretKey = ps.turnstileSecretKey; } - if (ps.enableFC !== undefined) { - set.enableFC = ps.enableFC; - } - if (ps.enableTestcaptcha !== undefined) { set.enableTestcaptcha = ps.enableTestcaptcha; } + if (ps.enableFC !== undefined) { + set.enableFC = ps.enableFC; + } + if (ps.fcSiteKey !== undefined) { set.fcSiteKey = ps.fcSiteKey; } @@ -417,10 +429,6 @@ export default class extends Endpoint { // eslint- set.enableBotTrending = ps.enableBotTrending; } - if (ps.proxyAccountId !== undefined) { - set.proxyAccountId = ps.proxyAccountId; - } - if (ps.maintainerName !== undefined) { set.maintainerName = ps.maintainerName; } @@ -553,6 +561,10 @@ export default class extends Endpoint { // eslint- set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; } + if (ps.translationTimeout !== undefined) { + set.translationTimeout = ps.translationTimeout; + } + if (ps.deeplAuthKey !== undefined) { if (ps.deeplAuthKey === '') { set.deeplAuthKey = null; @@ -577,6 +589,22 @@ export default class extends Endpoint { // eslint- } } + if (ps.libreTranslateURL !== undefined) { + if (ps.libreTranslateURL === '') { + set.libreTranslateURL = null; + } else { + set.libreTranslateURL = ps.libreTranslateURL; + } + } + + if (ps.libreTranslateKey !== undefined) { + if (ps.libreTranslateKey === '') { + set.libreTranslateKey = null; + } else { + set.libreTranslateKey = ps.libreTranslateKey; + } + } + if (ps.enableIpLogging !== undefined) { set.enableIpLogging = ps.enableIpLogging; } @@ -735,6 +763,14 @@ export default class extends Endpoint { // eslint- set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } + if (ps.allowUnsignedFetch !== undefined) { + set.allowUnsignedFetch = ps.allowUnsignedFetch; + } + + if (ps.enableProxyAccount !== undefined) { + set.enableProxyAccount = ps.enableProxyAccount; + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); @@ -742,9 +778,29 @@ export default class extends Endpoint { // eslint- const after = await this.metaService.fetch(true); this.moderationLogService.log(me, 'updateServerSettings', { - before, - after, + before: sanitize(before), + after: sanitize(after), }); }); } } + +function sanitize(meta: Partial): Partial { + return { + ...meta, + hcaptchaSecretKey: '', + mcaptchaSecretKey: '', + recaptchaSecretKey: '', + turnstileSecretKey: '', + fcSecretKey: '', + smtpPass: '', + swPrivateKey: '', + objectStorageAccessKey: '', + objectStorageSecretKey: '', + deeplAuthKey: '', + libreTranslateKey: '', + verifymailAuthKey: '', + truemailAuthKey: '', + }; +} + diff --git a/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts new file mode 100644 index 0000000000..6c9612c71a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { + descriptionSchema, +} from '@/models/User.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:account', + + res: { + type: 'object', + nullable: false, optional: false, + ref: 'UserDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + description: { ...descriptionSchema, nullable: true }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private userEntityService: UserEntityService, + private moderationLogService: ModerationLogService, + private systemAccountService: SystemAccountService, + ) { + super(meta, paramDef, async (ps, me) => { + const proxy = await this.systemAccountService.updateCorrespondingUserProfile('proxy', { + description: ps.description, + }); + + const updated = await this.userEntityService.pack(proxy.id, proxy, { + schema: 'MeDetailed', + }); + + if (ps.description !== undefined) { + this.moderationLogService.log(me, 'updateProxyAccountDescription', { + before: null, //TODO + after: ps.description, + }); + } + + return updated; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 9a6caa3317..55d686e390 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -79,6 +79,7 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, + excludeNotesInSensitiveChannel: { type: 'boolean' }, }, required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const; @@ -139,6 +140,7 @@ export default class extends Endpoint { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, + excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, }); this.globalEventService.publishInternalEvent('antennaCreated', antenna); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index be4cf1e0ca..e975b9ad0f 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -8,13 +8,13 @@ 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 { trackPromise } from '@/misc/promise-tracker.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -65,9 +65,6 @@ export const paramDef = { @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, @@ -77,9 +74,9 @@ export default class extends Endpoint { // eslint- private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, - private noteReadService: NoteReadService, private fanoutTimelineService: FanoutTimelineService, private globalEventService: GlobalEventService, + private readonly activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -111,7 +108,8 @@ export default class extends Endpoint { // eslint- return []; } - const query = this.notesRepository.createQueryBuilder('note') + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId) .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') @@ -119,18 +117,20 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 + // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 + + this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(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); - } - this.noteReadService.read(me.id, notes); + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 919a4cb3f5..3f2513bf75 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -78,6 +78,7 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, + excludeNotesInSensitiveChannel: { type: 'boolean' }, }, required: ['antennaId'], } as const; @@ -135,6 +136,7 @@ export default class extends Endpoint { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, + excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, isActive: true, lastUsedAt: new Date(), }); diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 14286bc23e..e3e68b50af 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -3,10 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; +import { isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage } from '@/core/activitypub/type.js'; +import { ApiError } from '@/server/api/error.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import type { NotesRepository } from '@/models/_.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; export const meta = { tags: ['federation'], @@ -21,6 +28,16 @@ export const meta = { }, errors: { + noInputSpecified: { + message: 'uri, userId, or noteId must be specified.', + code: 'NO_INPUT_SPECIFIED', + id: 'b43ff2a7-e7a2-4237-ad7f-7b079563c09e', + }, + multipleInputsSpecified: { + message: 'Only one of uri, userId, or noteId can be specified', + code: 'MULTIPLE_INPUTS_SPECIFIED', + id: 'f1aa27ed-8f20-44f3-a92a-fe073c8ca52b', + }, }, res: { @@ -32,19 +49,57 @@ export const meta = { export const paramDef = { type: 'object', properties: { - uri: { type: 'string' }, + uri: { type: 'string', nullable: true }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, + noteId: { type: 'string', format: 'misskey:id', nullable: true }, + expandCollectionItems: { type: 'boolean' }, + expandCollectionLimit: { type: 'integer', nullable: true }, + allowAnonymous: { type: 'boolean' }, }, - required: ['uri'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, + + private readonly cacheService: CacheService, + private readonly userEntityService: UserEntityService, + private readonly noteEntityService: NoteEntityService, private apResolverService: ApResolverService, ) { - super(meta, paramDef, async (ps, me) => { + super(meta, paramDef, async (ps) => { + if (ps.uri && ps.userId && ps.noteId) { + throw new ApiError(meta.errors.multipleInputsSpecified); + } + + let uri: string; + if (ps.uri) { + uri = ps.uri; + } else if (ps.userId) { + const user = await this.cacheService.findUserById(ps.userId); + uri = user.uri ?? this.userEntityService.genLocalUserUri(ps.userId); + } else if (ps.noteId) { + const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId }); + uri = note.uri ?? this.noteEntityService.genLocalNoteUri(ps.noteId); + } else { + throw new ApiError(meta.errors.noInputSpecified); + } + const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(ps.uri); + const object = await resolver.resolve(uri, ps.allowAnonymous ?? false); + + if (ps.expandCollectionItems && isCollectionOrOrderedCollection(object)) { + const items = await resolver.resolveCollectionItems(object, ps.expandCollectionLimit, ps.allowAnonymous ?? false); + + if (isOrderedCollection(object) || isOrderedCollectionPage(object)) { + object.orderedItems = items; + } else { + object.items = items; + } + } + 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 22bec8ef95..d631b002cc 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; 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, getNullableApId } from '@/core/activitypub/type.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'; @@ -18,7 +18,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; import { ApiError } from '../../error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -30,7 +30,8 @@ export const meta = { // Up to 30 calls, then 1 per 1/2 second limit: { - max: 30, + type: 'bucket', + size: 30, dripRate: 500, }, @@ -55,11 +56,6 @@ export const meta = { code: 'RESPONSE_INVALID', id: '70193c39-54f3-4813-82f0-70a680f7495b', }, - responseInvalidIdHostNotMatch: { - message: 'Requested URI and response URI host does not match.', - code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH', - id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a', - }, noSuchObject: { message: 'No such object.', code: 'NO_SUCH_OBJECT', @@ -123,7 +119,7 @@ export default class extends Endpoint { // eslint- private apPersonService: ApPersonService, private apNoteService: ApNoteService, private readonly apRequestService: ApRequestService, - private readonly instanceActorService: InstanceActorService, + private readonly systemAccountService: SystemAccountService, ) { super(meta, paramDef, async (ps, me) => { const object = await this.fetchAny(ps.uri, me); @@ -144,7 +140,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.federationNotAllowed); } - let local = await this.mergePack(me, ...await Promise.all([ + const local = await this.mergePack(me, ...await Promise.all([ this.apDbResolverService.getUserFromApId(uri), this.apDbResolverService.getNoteFromApId(uri), ])); @@ -153,7 +149,7 @@ export default class extends Endpoint { // eslint- // No local object found with that uri. // Before we fetch, resolve the URI in case it has a cross-origin redirect or anything like that. // Resolver.resolve() uses strict verification, which is overly paranoid for a user-provided lookup. - uri = await this.resolveCanonicalUri(uri); // eslint-disable-line no-param-reassign + uri = await this.resolveCanonicalUri(uri); if (!this.utilityService.isFederationAllowedUri(uri)) { throw new ApiError(meta.errors.federationNotAllowed); } @@ -177,10 +173,11 @@ export default class extends Endpoint { // eslint- case '09d79f9e-64f1-4316-9cfa-e75c4d091574': throw new ApiError(meta.errors.federationNotAllowed); case '72180409-793c-4973-868e-5a118eb5519b': - case 'ad2dc287-75c1-44c4-839d-3d2e64576675': + case 'd09dc850-b76c-4f45-875a-7389339d78b8': + case 'dc110060-a5f2-461d-808b-39c62702ca64': + case '45793ab7-7648-4886-b503-429f8a0d0f73': + case '4bf8f36b-4d33-4ac9-ad76-63fa11f354e9': throw new ApiError(meta.errors.responseInvalid); - case 'fd93c2fa-69a8-440f-880b-bf178e0ec877': - throw new ApiError(meta.errors.responseInvalidIdHostNotMatch); // resolveLocal case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8': @@ -196,25 +193,13 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.requestFailed); }); - if (object.id == null) { - throw new ApiError(meta.errors.responseInvalid); - } - - // /@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; - } - - // 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない + // Object is already validated to have a valid id (URI). + // We can pass it through with the same resolver and sentFrom to avoid a duplicate fetch. + // The resolve* methods automatically check for locally cached copies. return await this.mergePack( me, - isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, - isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null, + isActor(object) ? await this.apPersonService.resolvePerson(object, resolver, uri) : null, + isPost(object) ? await this.apNoteService.resolveNote(object, { resolver, sentFrom: uri }) : null, ); } @@ -245,8 +230,8 @@ export default class extends Endpoint { // eslint- * Resolves an arbitrary URI to its canonical, post-redirect form. */ private async resolveCanonicalUri(uri: string): Promise { - const user = await this.instanceActorService.getInstanceActor(); + const user = await this.systemAccountService.getInstanceActor(); const res = await this.apRequestService.signedGet(uri, user, true); - return getNullableApId(res) ?? uri; + return getApId(res); } } diff --git a/packages/backend/src/server/api/endpoints/app/current.ts b/packages/backend/src/server/api/endpoints/app/current.ts new file mode 100644 index 0000000000..39b5ef347c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/current.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey 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 { ApiError } from '../../error.js'; + +export const meta = { + tags: ['app'], + + errors: { + credentialRequired: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401, + }, + noAppLogin: { + message: 'Not logged in with an app.', + code: 'NO_APP_LOGIN', + id: '339a4ad2-48c3-47fc-bd9d-2408f05120f8', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'App', + }, + + // 10 calls per 5 seconds + limit: { + duration: 1000 * 5, + 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( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, + + private appEntityService: AppEntityService, + ) { + super(meta, paramDef, async (_, user, token) => { + if (!user) { + throw new ApiError(meta.errors.credentialRequired); + } + if (!token || !token.appId) { + throw new ApiError(meta.errors.noAppLogin); + } + + const app = token.app ?? await this.appsRepository.findOneByOrFail({ id: token.appId }); + + return await this.appEntityService.pack(app, user, { + detail: true, + includeSecret: false, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 0bd01d712c..fa5b948eca 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -96,7 +96,11 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - if (me) this.activeUsersChart.read(me); + if (me) { + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + } if (!this.serverSettings.enableFanoutTimeline) { return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me); @@ -135,28 +139,28 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('note.channel', 'channel') + .limit(ps.limit); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - - 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 != \'{}\''); - })); - })); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } //#endregion - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } 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 dcdcf46d0b..9f5064fe83 100644 --- a/packages/backend/src/server/api/endpoints/charts/active-users.ts +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; 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 28c64229e7..68dc87546e 100644 --- a/packages/backend/src/server/api/endpoints/charts/ap-request.ts +++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts index 69ff3c5d7a..c0bfb00608 100644 --- a/packages/backend/src/server/api/endpoints/charts/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts index bd870cc3d9..bd15700670 100644 --- a/packages/backend/src/server/api/endpoints/charts/federation.ts +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts index 765bf024ee..e1053d05d8 100644 --- a/packages/backend/src/server/api/endpoints/charts/instance.ts +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts index ecac436311..4550e2f17e 100644 --- a/packages/backend/src/server/api/endpoints/charts/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; 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 98ec40ade2..9475a8ab0a 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; 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 cb3dd36bab..1d333f9a9b 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/following.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -8,6 +8,8 @@ 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 { CacheService } from '@/core/CacheService.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['charts', 'users', 'following'], @@ -17,11 +19,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; @@ -40,9 +42,84 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private perUserFollowingChart: PerUserFollowingChart, + private readonly cacheService: CacheService, + private readonly roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - return await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); + const profile = await this.cacheService.userProfileCache.fetch(ps.userId); + + // These are structured weird to avoid un-necessary calls to roleService and cacheService + const iAmModeratorOrTarget = me && (me.id === ps.userId || await this.roleService.isModerator(me)); + const iAmFollowingOrTarget = me && (me.id === ps.userId || await this.cacheService.isFollowing(me.id, ps.userId)); + + const canViewFollowing = + profile.followingVisibility === 'public' + || iAmModeratorOrTarget + || (profile.followingVisibility === 'followers' && iAmFollowingOrTarget); + + const canViewFollowers = + profile.followersVisibility === 'public' + || iAmModeratorOrTarget + || (profile.followersVisibility === 'followers' && iAmFollowingOrTarget); + + if (!canViewFollowing && !canViewFollowers) { + return { + local: { + followings: { + total: [], + inc: [], + dec: [], + }, + followers: { + total: [], + inc: [], + dec: [], + }, + }, + remote: { + followings: { + total: [], + inc: [], + dec: [], + }, + followers: { + total: [], + inc: [], + dec: [], + }, + }, + }; + } + + const chart = await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); + + if (!canViewFollowers) { + chart.local.followers = { + total: [], + inc: [], + dec: [], + }; + chart.remote.followers = { + total: [], + inc: [], + dec: [], + }; + } + + if (!canViewFollowing) { + chart.local.followings = { + total: [], + inc: [], + dec: [], + }; + chart.remote.followings = { + total: [], + inc: [], + dec: [], + }; + } + + return chart; }); } } 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 0742a21210..1d24dc2b77 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/pv.ts b/packages/backend/src/server/api/endpoints/charts/user/pv.ts index a220381b00..e0026d5ff3 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/pv.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/pv.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; 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 3bb33622c2..c15056466f 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts index b5452517ab..0f96fae202 100644 --- a/packages/backend/src/server/api/endpoints/charts/users.ts +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -17,11 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // Burst up to 100, then 2/sec average + // Burst up to 200, then 5/sec average limit: { type: 'bucket', - size: 100, - dripRate: 500, + size: 200, + dripRate: 200, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts new file mode 100644 index 0000000000..fdd9055106 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/history.ts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + room: { type: 'boolean', default: false }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit); + + const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me); + + if (ps.room) { + const roomIds = history.map(m => m.toRoomId!); + const readStateMap = await this.chatService.getRoomReadStateMap(me.id, roomIds); + + for (const message of packedMessages) { + message.isRead = readStateMap[message.toRoomId!] ?? false; + } + } else { + const otherIds = history.map(m => m.fromUserId === me.id ? m.toUserId! : m.fromUserId!); + const readStateMap = await this.chatService.getUserReadStateMap(me.id, otherIds); + + for (const message of packedMessages) { + const otherId = message.fromUserId === me.id ? message.toUserId! : message.fromUserId!; + message.isRead = readStateMap[otherId] ?? false; + } + } + + return packedMessages; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts new file mode 100644 index 0000000000..ad2b82e219 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import type { DriveFilesRepository, MiUser } from '@/models/_.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1hour'), + max: 500, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLiteForRoom', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '8098520d-2da5-4e8f-8ee1-df78b55a4ec6', + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b6accbd3-1d7b-4d9f-bdb7-eb185bac06db', + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '340517b7-6d04-42c0-bac1-37ee804e3594', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + text: { type: 'string', nullable: true, maxLength: 2000 }, + fileId: { type: 'string', format: 'misskey:id' }, + toRoomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['toRoomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.findRoomById(ps.toRoomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + let file = null; + if (ps.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await this.chatService.createMessageToRoom(me, room, { + text: ps.text, + file: file, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts new file mode 100644 index 0000000000..fa34a7d558 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import type { DriveFilesRepository, MiUser } from '@/models/_.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1hour'), + max: 500, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLiteFor1on1', + }, + + 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', + }, + + 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: 2000 }, + fileId: { type: 'string', format: 'misskey:id' }, + toUserId: { type: 'string', format: 'misskey:id' }, + }, + required: ['toUserId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + let file = null; + if (ps.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + // Myself + if (ps.toUserId === me.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + const toUser = await this.getterService.getUser(ps.toUserId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + return await this.chatService.createMessageToUser(me, toUser, { + text: ps.text, + file: file, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts new file mode 100644 index 0000000000..52a054303b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '36b67f0e-66a6-414b-83df-992a55294f17', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const message = await this.chatService.findMyMessageById(me.id, ps.messageId); + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + await this.chatService.deleteMessage(message); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/react.ts b/packages/backend/src/server/api/endpoints/chat/messages/react.ts new file mode 100644 index 0000000000..2197e7bf80 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/react.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '9b5839b9-0ba0-4351-8c35-37082093d200', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + reaction: { type: 'string' }, + }, + required: ['messageId', 'reaction'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.react(ps.messageId, me.id, ps.reaction); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts new file mode 100644 index 0000000000..c0e344b889 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLiteForRoom', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f', + }, + }, +} 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' }, + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!await this.chatService.hasPermissionToViewRoomTimeline(me.id, room)) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const messages = await this.chatService.roomTimeline(room.id, ps.limit, ps.sinceId, ps.untilId); + + this.chatService.readRoomChatMessage(me.id, room.id); + + return await this.chatEntityService.packMessagesLiteForRoom(messages); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/search.ts b/packages/backend/src/server/api/endpoints/chat/messages/search.ts new file mode 100644 index 0000000000..682597f76d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/search.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '460b3669-81b0-4dc9-a997-44442141bf83', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string', minLength: 1, maxLength: 256 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, + roomId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: ['query'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + if (ps.roomId != null) { + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!(await this.chatService.isRoomMember(room, me.id))) { + throw new ApiError(meta.errors.noSuchRoom); + } + } + + const messages = await this.chatService.searchMessages(me.id, ps.query, ps.limit, { + userId: ps.userId, + roomId: ps.roomId, + }); + + return await this.chatEntityService.packMessagesDetailed(messages, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts new file mode 100644 index 0000000000..9a2bbb8742 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { GetterService } from '@/server/api/GetterService.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; +import { RoleService } from '@/core/RoleService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '3710865b-1848-4da9-8d61-cfed15510b93', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private roleService: RoleService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const message = await this.chatService.findMessageById(ps.messageId); + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + if (message.fromUserId !== me.id && message.toUserId !== me.id && !(await this.roleService.isModerator(me))) { + throw new ApiError(meta.errors.noSuchMessage); + } + return this.chatEntityService.packMessageDetailed(message, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts new file mode 100644 index 0000000000..adfcd232f9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: 'c39ea42f-e3ca-428a-ad57-390e0a711595', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + reaction: { type: 'string' }, + }, + required: ['messageId', 'reaction'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.unreact(ps.messageId, me.id, ps.reaction); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts new file mode 100644 index 0000000000..a057e2e088 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { GetterService } from '@/server/api/GetterService.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLiteFor1on1', + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + }, +} 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' }, + }, + required: ['userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const other = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, ps.sinceId, ps.untilId); + + this.chatService.readUserChatMessage(me.id, other.id); + + return await this.chatEntityService.packMessagesLiteFor1on1(messages); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts new file mode 100644 index 0000000000..68a53f0886 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1day'), + max: 10, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', maxLength: 256 }, + description: { type: 'string', maxLength: 1024 }, + }, + required: ['name'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.createRoom(me, { + name: ps.name, + description: ps.description ?? '', + }); + return await this.chatEntityService.packRoom(room); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts new file mode 100644 index 0000000000..1ea81448c1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!await this.chatService.hasPermissionToDeleteRoom(me.id, room)) { + throw new ApiError(meta.errors.noSuchRoom); + } + + await this.chatService.deleteRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts new file mode 100644 index 0000000000..b1f049f2b9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1day'), + max: 50, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '916f9507-49ba-4e90-b57f-1fd4deaa47a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId', 'userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + const invitation = await this.chatService.createRoomInvitation(me.id, room.id, ps.userId); + return await this.chatEntityService.packRoomInvitation(invitation, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts new file mode 100644 index 0000000000..88ea234527 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '5130557e-5a11-4cfb-9cc5-fe60cda5de0d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.ignoreRoomInvitation(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts new file mode 100644 index 0000000000..8a02d1c704 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + }, + + errors: { + }, +} 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' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRoomInvitations(invitations, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts new file mode 100644 index 0000000000..0702ba086c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'a3c6b309-9717-4316-ae94-a69b53437237', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const invitations = await this.chatService.getSentRoomInvitationsWithPagination(ps.roomId, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRoomInvitations(invitations, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts new file mode 100644 index 0000000000..550b4da1a6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '84416476-5ce8-4a2c-b568-9569f1b10733', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.joinToRoom(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts new file mode 100644 index 0000000000..ba9242c762 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomMembership', + }, + }, + + errors: { + }, +} 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' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId); + + return this.chatEntityService.packRoomMemberships(memberships, me, { + populateUser: false, + populateRoom: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts new file mode 100644 index 0000000000..f99b408d67 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'cb7f3179-50e8-4389-8c30-dbe2650a67c9', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.leaveRoom(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts new file mode 100644 index 0000000000..f5ffa21d32 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomMembership', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '7b9fe84c-eafc-4d21-bf89-485458ed2c18', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!(await this.chatService.isRoomMember(room, me.id))) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const memberships = await this.chatService.getRoomMembershipsWithPagination(room.id, ps.limit, ps.sinceId, ps.untilId); + + return this.chatEntityService.packRoomMemberships(memberships, me, { + populateUser: true, + populateRoom: false, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts new file mode 100644 index 0000000000..ee60f92505 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'c2cde4eb-8d0f-42f1-8f2f-c4d6bfc8e5df', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + mute: { type: 'boolean' }, + }, + required: ['roomId', 'mute'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.muteRoom(me.id, ps.roomId, ps.mute); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts new file mode 100644 index 0000000000..accf7e1bee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + }, + + errors: { + }, +} 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' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRooms(rooms, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts new file mode 100644 index 0000000000..50da210d81 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '857ae02f-8759-4d20-9adb-6e95fffe4fd7', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + return this.chatEntityService.packRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts new file mode 100644 index 0000000000..0cd62cb040 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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 { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'fcdb0f92-bda6-47f9-bd05-343e0e020932', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', maxLength: 256 }, + description: { type: 'string', maxLength: 1024 }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const updated = await this.chatService.updateRoom(room, { + name: ps.name, + description: ps.description, + }); + + return this.chatEntityService.packRoom(updated, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index c2f72ad9ae..e3b8f33f97 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -45,7 +45,7 @@ export const paramDef = { properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, isPublic: { type: 'boolean', default: false }, - description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, + description: { type: 'string', nullable: true, maxLength: 2048 }, }, required: ['name'], } as const; @@ -59,7 +59,9 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { let clip: MiClip; try { - clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description ?? null); + // 空文字列をnullにしたいので??は使わない + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + 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); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 6175c4d0e5..4758dbad00 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -91,10 +91,12 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } const notes = await query diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index b776f1357d..beab427b69 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -45,7 +45,7 @@ export const paramDef = { clipId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, isPublic: { type: 'boolean' }, - description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, + description: { type: 'string', nullable: true, maxLength: 2048 }, }, required: ['clipId'], } as const; @@ -59,7 +59,9 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { try { - await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description); + // 空文字列をnullにしたいので??は使わない + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description || null); } catch (e) { if (e instanceof ClipService.NoSuchClipError) { throw new ApiError(meta.errors.noSuchClip); diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index 5df212415d..26b7b32001 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -46,6 +46,7 @@ export const paramDef = { 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', null] }, searchQuery: { type: 'string', default: '' }, + showAll: { type: 'boolean', default: false }, }, required: [], } as const; @@ -63,10 +64,12 @@ export default class extends Endpoint { // eslint- 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.showAll) { + if (ps.folderId) { + query.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); + } else { + query.andWhere('file.folderId IS NULL'); + } } if (ps.searchQuery.length > 0) { 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 32c2620915..9d70044db8 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 @@ -81,10 +81,22 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchFile); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); - query.andWhere(':file <@ note.fileIds', { file: [file.id] }); + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(':file <@ note.fileIds', { file: [file.id] }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); - const notes = await query.limit(ps.limit).getMany(); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + + const notes = await query.getMany(); return await this.noteEntityService.packMany(notes, me, { detail: true, 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 f67ff6ddc4..939eadad9b 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -10,6 +10,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveService } from '@/core/DriveService.js'; import type { Config } from '@/config.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ApiError } from '../../../error.js'; import { MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -62,6 +64,13 @@ export const meta = { code: 'COMMENT_TOO_LONG', id: '333652d9-0826-40f5-a2c3-e2bedcbb9fe5', }, + + maxFileSizeExceeded: { + message: 'Cannot upload the file because it exceeds the maximum file size.', + code: 'MAX_FILE_SIZE_EXCEEDED', + id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a', + httpStatusCode: 413, + }, }, } as const; @@ -88,6 +97,7 @@ export default class extends Endpoint { // eslint- private driveFileEntityService: DriveFileEntityService, private driveService: DriveService, + private readonly apiLoggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => { // Get 'name' parameter @@ -123,13 +133,14 @@ export default class extends Endpoint { // eslint- return await this.driveFileEntityService.pack(driveFile, { self: true }); } catch (err) { if (err instanceof Error || typeof err === 'string') { - console.error(err); + this.apiLoggerService.logger.error(`Error saving drive file: ${renderInlineError(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); + if (err.id === 'f9e4e5f3-4df4-40b5-b400-f236945f7073') throw new ApiError(meta.errors.maxFileSizeExceeded); } - throw new ApiError(); + throw err; } finally { cleanup!(); } diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 97384d2498..eeaebc3708 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -113,11 +113,10 @@ export default class extends Endpoint { // eslint- } 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 }); + query.andWhere('instance.host IN (select unnest("blockedHosts") as x from "meta")'); } else { - query.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere('instance.host NOT IN (select unnest("blockedHosts") as x from "meta")'); } } @@ -146,36 +145,18 @@ export default class extends Endpoint { // eslint- } 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, - }); + query.andWhere('instance.host IN (select unnest("silencedHosts") as x from "meta")'); + } else { + query.andWhere('instance.host NOT IN (select unnest("silencedHosts") as x from "meta")'); } } if (typeof ps.bubble === 'boolean') { - const meta = await this.metaService.fetch(true); - if (ps.bubble) { - if (meta.bubbleInstances.length === 0) { - return []; - } - query.andWhere('instance.host IN (:...bubble)', { - bubble: meta.bubbleInstances, - }); - } else if (meta.bubbleInstances.length > 0) { - query.andWhere('instance.host NOT IN (:...bubble)', { - bubble: meta.bubbleInstances, - }); + query.andWhere('instance.host IN (select unnest("bubbleInstances") as x from "meta")'); + } else { + query.andWhere('instance.host NOT IN (select unnest("bubbleInstances") as x from "meta")'); } } 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 5217f79065..67fa5ed343 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 @@ -16,7 +16,8 @@ export const meta = { // Up to 10 calls, then 4 / second. // This allows for reliable automation. limit: { - max: 10, + type: 'bucket', + size: 10, dripRate: 250, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/fetch-rss.ts b/packages/backend/src/server/api/endpoints/fetch-rss.ts index 03f35f16a5..11244b30f6 100644 --- a/packages/backend/src/server/api/endpoints/fetch-rss.ts +++ b/packages/backend/src/server/api/endpoints/fetch-rss.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import Parser from 'rss-parser'; import { Injectable } from '@nestjs/common'; +import { parseFeed } from 'htmlparser2'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; - -const rssParser = new Parser(); +import { ApiError } from '../error.js'; +import type { FeedItem } from 'domutils'; export const meta = { tags: ['meta'], @@ -17,52 +17,32 @@ export const meta = { allowGet: true, cacheSec: 60 * 3, + errors: { + fetchFailed: { + id: '88f4356f-719d-4715-b4fc-703a10a812d2', + code: 'FETCH_FAILED', + message: 'Failed to fetch RSS feed', + }, + }, + res: { type: 'object', properties: { - image: { - type: 'object', - optional: true, - properties: { - link: { - type: 'string', - optional: true, - }, - url: { - type: 'string', - optional: false, - }, - title: { - type: 'string', - optional: true, - }, - }, + type: { + type: 'string', + optional: false, }, - paginationLinks: { - type: 'object', + id: { + type: 'string', + optional: true, + }, + updated: { + type: 'string', + optional: true, + }, + author: { + type: 'string', optional: true, - properties: { - self: { - type: 'string', - optional: true, - }, - first: { - type: 'string', - optional: true, - }, - next: { - type: 'string', - optional: true, - }, - last: { - type: 'string', - optional: true, - }, - prev: { - type: 'string', - optional: true, - }, - }, }, link: { type: 'string', @@ -94,113 +74,42 @@ export const meta = { type: 'string', optional: true, }, - creator: { + description: { type: 'string', optional: true, }, - summary: { - type: 'string', - optional: true, - }, - content: { - type: 'string', - optional: true, - }, - isoDate: { - type: 'string', - optional: true, - }, - categories: { + media: { type: 'array', - optional: true, + optional: false, items: { - type: 'string', - }, - }, - contentSnippet: { - type: 'string', - optional: true, - }, - enclosure: { - type: 'object', - optional: true, - properties: { - url: { - type: 'string', - optional: false, - }, - length: { - type: 'number', - optional: true, - }, - type: { - type: 'string', - optional: true, + type: 'object', + properties: { + medium: { + type: 'string', + optional: true, + }, + url: { + type: 'string', + optional: true, + }, + type: { + type: 'string', + optional: true, + }, + lang: { + type: 'string', + optional: true, + }, }, }, }, }, }, }, - feedUrl: { - type: 'string', - optional: true, - }, description: { type: 'string', optional: true, }, - itunes: { - type: 'object', - optional: true, - additionalProperties: true, - properties: { - image: { - type: 'string', - optional: true, - }, - owner: { - type: 'object', - optional: true, - properties: { - name: { - type: 'string', - optional: true, - }, - email: { - type: 'string', - optional: true, - }, - }, - }, - author: { - type: 'string', - optional: true, - }, - summary: { - type: 'string', - optional: true, - }, - explicit: { - type: 'string', - optional: true, - }, - categories: { - type: 'array', - optional: true, - items: { - type: 'string', - }, - }, - keywords: { - type: 'array', - optional: true, - items: { - type: 'string', - }, - }, - }, - }, }, }, @@ -224,7 +133,7 @@ export default class extends Endpoint { // eslint- constructor( private httpRequestService: HttpRequestService, ) { - super(meta, paramDef, async (ps, me) => { + super(meta, paramDef, async (ps) => { const res = await this.httpRequestService.send(ps.url, { method: 'GET', headers: { @@ -234,8 +143,38 @@ export default class extends Endpoint { // eslint- }); const text = await res.text(); + const feed = parseFeed(text, { + xmlMode: true, + }); - return rssParser.parseString(text); + if (!feed) { + throw new ApiError(meta.errors.fetchFailed); + } + + return { + type: feed.type, + id: feed.id, + title: feed.title, + link: feed.link, + description: feed.description, + updated: feed.updated?.toISOString(), + author: feed.author, + items: feed.items + .filter((item): item is FeedItem & { link: string, title: string } => !!item.link && !!item.title) + .map(item => ({ + guid: item.id, + title: item.title, + link: item.link, + description: item.description, + pubDate: item.pubDate?.toISOString(), + media: item.media.map(media => ({ + medium: media.medium, + url: media.url, + type: media.type, + lang: media.lang, + })), + })), + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts index ba146b6703..442352a4d2 100644 --- a/packages/backend/src/server/api/endpoints/following/delete.ts +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -12,6 +12,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApiError } from '../../error.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['following', 'users'], @@ -69,6 +70,7 @@ export default class extends Endpoint { // eslint- private userEntityService: UserEntityService, private getterService: GetterService, private userFollowingService: UserFollowingService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const follower = me; @@ -85,12 +87,7 @@ export default class extends Endpoint { // eslint- }); // Check not following - const exist = await this.followingsRepository.exists({ - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); + const exist = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(followee.id)); if (!exist) { throw new ApiError(meta.errors.notFollowing); diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts index 8935c2c2da..3809bf29b0 100644 --- a/packages/backend/src/server/api/endpoints/following/invalidate.ts +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -11,6 +11,7 @@ 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 { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -69,6 +70,7 @@ export default class extends Endpoint { // eslint- private userEntityService: UserEntityService, private getterService: GetterService, private userFollowingService: UserFollowingService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const followee = me; @@ -85,18 +87,15 @@ export default class extends Endpoint { // eslint- }); // Check not following - const exist = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(followee.id)); - if (exist == null) { + if (!isFollowing) { throw new ApiError(meta.errors.notFollowing); } await this.userFollowingService.unfollow(follower, followee); - return await this.userEntityService.pack(followee.id, me); + return await this.userEntityService.pack(follower.id, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/following/update-all.ts b/packages/backend/src/server/api/endpoints/following/update-all.ts index c953feb393..a02b51cc79 100644 --- a/packages/backend/src/server/api/endpoints/following/update-all.ts +++ b/packages/backend/src/server/api/endpoints/following/update-all.ts @@ -12,6 +12,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApiError } from '../../error.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['following', 'users'], @@ -39,6 +40,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { await this.followingsRepository.update({ @@ -48,6 +50,8 @@ export default class extends Endpoint { // eslint- withReplies: ps.withReplies != null ? ps.withReplies : undefined, }); + await this.cacheService.refreshFollowRelationsFor(me.id); + return; }); } diff --git a/packages/backend/src/server/api/endpoints/following/update.ts b/packages/backend/src/server/api/endpoints/following/update.ts index d62cf210ed..f4ca21856f 100644 --- a/packages/backend/src/server/api/endpoints/following/update.ts +++ b/packages/backend/src/server/api/endpoints/following/update.ts @@ -11,6 +11,7 @@ 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 { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -71,6 +72,7 @@ export default class extends Endpoint { // eslint- private userEntityService: UserEntityService, private getterService: GetterService, private userFollowingService: UserFollowingService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const follower = me; @@ -87,10 +89,7 @@ export default class extends Endpoint { // eslint- }); // Check not following - const exist = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); + const exist = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.get(followee.id)); if (exist == null) { throw new ApiError(meta.errors.notFollowing); @@ -103,6 +102,8 @@ export default class extends Endpoint { // eslint- withReplies: ps.withReplies != null ? ps.withReplies : undefined, }); + await this.cacheService.refreshFollowRelationsFor(follower.id); + return await this.userEntityService.pack(follower.id, 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 504a9c789e..08abd7fed5 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -72,7 +72,7 @@ export default class extends Endpoint { // eslint- ))).filter(x => x != null); if (files.length === 0) { - throw new Error(); + throw new Error('no files specified'); } const post = await this.galleryPostsRepository.insertOne(new MiGalleryPost({ 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 e73110648c..ae8ad6c044 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -98,7 +98,7 @@ export default class extends Endpoint { // eslint- // ランキング更新 if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { - await this.featuredService.updateGalleryPostsRanking(post.id, 1); + await this.featuredService.updateGalleryPostsRanking(post, 1); } this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1); 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 b0fad1eff2..be0a5a5584 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -81,7 +81,7 @@ export default class extends Endpoint { // eslint- // ランキング更新 if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) { - await this.featuredService.updateGalleryPostsRanking(post.id, -1); + await this.featuredService.updateGalleryPostsRanking(post, -1); } this.galleryPostsRepository.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 5243ee9603..d0f9b56863 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -73,7 +73,7 @@ export default class extends Endpoint { // eslint- ))).filter(x => x != null); if (files.length === 0) { - throw new Error(); + throw new Error('no files'); } } diff --git a/packages/backend/src/server/api/endpoints/hashtags/list.ts b/packages/backend/src/server/api/endpoints/hashtags/list.ts index f378c5558e..b49c907432 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/list.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/list.ts @@ -58,6 +58,12 @@ export default class extends Endpoint { // eslint- if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0'); if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0'); + // Ignore hidden hashtags + query.andWhere(` + NOT EXISTS ( + SELECT 1 FROM meta WHERE tag.name = ANY(meta."hiddenTags") + )`); + switch (ps.sort) { case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break; case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break; diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index eb2289960a..68c795de73 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -10,6 +10,7 @@ import { safeForSql } from "@/misc/safe-for-sql.js"; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { requireCredential: false, @@ -41,6 +42,7 @@ export const paramDef = { 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' }, + trending: { type: 'boolean', default: false }, }, required: ['tag', 'sort'], } as const; @@ -52,6 +54,7 @@ export default class extends Endpoint { // eslint- private usersRepository: UsersRepository, private userEntityService: UserEntityService, + private readonly roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); @@ -80,7 +83,18 @@ export default class extends Endpoint { // eslint- case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; } - const users = await query.limit(ps.limit).getMany(); + let users = await query.limit(ps.limit).getMany(); + + // This is not ideal, for a couple of reasons: + // 1. It may return less than "limit" results. + // 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early. + // Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB. + if (ps.trending) { + const usersWithRoles = await Promise.all(users.map(async u => [u, await this.roleService.getUserPolicies(u)] as const)); + users = usersWithRoles + .filter(([,p]) => p.canTrend) + .map(([u]) => u); + } return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 48a2e3b40a..177bc601ac 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -34,7 +34,8 @@ export const meta = { // up to 20 calls, then 1 per second. // This handles bursty traffic when all tabs reload as a group limit: { - max: 20, + type: 'bucket', + size: 20, dripSize: 1, dripRate: 1000, }, 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 370d9915a3..6d1972456d 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 @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; 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 893ea30391..77f71ce5fd 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 @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; 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 d27c14c69b..6fde3a90a7 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; 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 b01e452056..931c8d69b0 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 @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; @@ -71,7 +70,7 @@ export default class extends Endpoint { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); } catch (e) { - throw new Error('authentication failed'); + throw new Error('authentication failed', { cause: e }); } } 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 2fe4fdc4c0..fc5a51f81b 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; 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 index 4a41c7b984..a9f631cfaf 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; 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 4069683740..ea84ef24d7 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; @@ -65,7 +64,6 @@ export default class extends Endpoint { // eslint- } // Generate hash of password - //const salt = await bcrypt.genSalt(8); const hash = await argon2.hash(ps.newPassword); await this.userProfilesRepository.update(me.id, { 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 10fb923d4f..8a2b523449 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index bdf6c065e8..ccec96ffbb 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -16,7 +16,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportAntennas', + requiredRolePolicy: 'canImportAntennas', prohibitMoved: true, limit: { 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 d7bb6bcd22..2fa450558b 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportBlocking', + requiredRolePolicy: 'canImportBlocking', prohibitMoved: true, limit: { 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 e03192d8c6..9186fca162 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportFollowing', + requiredRolePolicy: 'canImportFollowing', prohibitMoved: true, limit: { duration: ms('1hour'), 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 76b285bb7e..b6dbacd371 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportMuting', + requiredRolePolicy: 'canImportMuting', prohibitMoved: true, limit: { 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 76ecfd082c..5de0a70bbb 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 @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportUserLists', + requiredRolePolicy: 'canImportUserLists', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts index 1bd641232c..e2a14b61af 100644 --- a/packages/backend/src/server/api/endpoints/i/move.ts +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -17,8 +17,10 @@ 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 { renderInlineError } from '@/misc/render-inline-error.js'; import * as Acct from '@/misc/acct.js'; +import { DI } from '@/di-symbols.js'; +import { MiMeta } from '@/models/_.js'; export const meta = { tags: ['users'], @@ -81,6 +83,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private remoteUserResolveService: RemoteUserResolveService, private apiLoggerService: ApiLoggerService, private accountMoveService: AccountMoveService, @@ -92,7 +97,7 @@ export default class extends Endpoint { // eslint- // 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); + if (this.serverSettings.rootUserId === me.id) throw new ApiError(meta.errors.rootForbidden); // abort if user has already moved if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); @@ -100,7 +105,7 @@ export default class extends Endpoint { // eslint- 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}`); + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${renderInlineError(e)}`); throw new ApiError(meta.errors.noSuchUser); }); const destination = await this.getterService.getUser(moveTo.id) as MiLocalUser | MiRemoteUser; diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index ad4577be58..444734070f 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -7,9 +7,13 @@ import { In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js'; +import { + obsoleteNotificationTypes, + groupedNotificationTypes, + FilterUnionByProperty, + notificationTypes, +} 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'; @@ -48,10 +52,10 @@ export const paramDef = { markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { - type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], } }, excludeTypes: { type: 'array', items: { - type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], } }, }, required: [], @@ -63,13 +67,9 @@ export default class extends Endpoint { // eslint- @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; @@ -79,31 +79,20 @@ export default class extends Endpoint { // eslint- return []; } // excludeTypes に全指定されている場合はクエリしない - if (groupedNotificationTypes.every(type => ps.excludeTypes?.includes(type))) { + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { return []; } const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[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)); - } + const notifications = await this.notificationService.getNotifications(me.id, { + sinceId: ps.sinceId, + untilId: ps.untilId, + limit: ps.limit, + includeTypes, + excludeTypes, + }); if (notifications.length === 0) { return []; @@ -115,61 +104,88 @@ export default class extends Endpoint { // eslint- } // 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)!; + const groupedNotifications : MiGroupedNotification[] = []; + // keep track of where reaction / renote notifications are, by note id + const reactionIdxByNoteId = new Map(); + const renoteIdxByNoteId = new Map(); - if (prev.type === 'reaction' && notification.type === 'reaction' && prev.noteId === notification.noteId) { - if (prevGroupedNotification.type !== 'reaction:grouped') { - groupedNotifications[groupedNotifications.length - 1] = { + // group notifications by type+note; notice that we don't try to + // split groups if they span a long stretch of time, because + // it's probably overkill: if the user has very few + // notifications, there should be very little difference; if the + // user has many notifications, the pagination will break the + // groups + + // scan `notifications` newest-to-oldest + for (let i = 0; i < notifications.length; i++) { + const notification = notifications[i]; + + if (notification.type === 'reaction') { + const reactionIdx = reactionIdxByNoteId.get(notification.noteId); + if (reactionIdx === undefined) { + // first reaction to this note that we see, add it as-is + // and remember where we put it + groupedNotifications.push(notification); + reactionIdxByNoteId.set(notification.noteId, groupedNotifications.length - 1); + continue; + } + + let prevReaction = groupedNotifications[reactionIdx] as FilterUnionByProperty | FilterUnionByProperty; + // if the previous reaction is not a group, make it into one + if (prevReaction.type !== 'reaction:grouped') { + prevReaction = groupedNotifications[reactionIdx] = { type: 'reaction:grouped', - id: '', - createdAt: prev.createdAt, - noteId: prev.noteId!, + id: prevReaction.id, // this will be the newest id in this group + createdAt: prevReaction.createdAt, + noteId: prevReaction.noteId!, reactions: [{ - userId: prev.notifierId!, - reaction: prev.reaction!, + userId: prevReaction.notifierId!, + reaction: prevReaction.reaction!, }], }; - prevGroupedNotification = groupedNotifications.at(-1)!; } - (prevGroupedNotification as FilterUnionByProperty).reactions.push({ + // add this new reaction to the existing group + (prevReaction 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; } + if (notification.type === 'renote') { + const renoteIdx = renoteIdxByNoteId.get(notification.targetNoteId); + if (renoteIdx === undefined) { + // first renote of this note that we see, add it as-is and + // remember where we put it + groupedNotifications.push(notification); + renoteIdxByNoteId.set(notification.targetNoteId, groupedNotifications.length - 1); + continue; + } + + let prevRenote = groupedNotifications[renoteIdx] as FilterUnionByProperty | FilterUnionByProperty; + // if the previous renote is not a group, make it into one + if (prevRenote.type !== 'renote:grouped') { + prevRenote = groupedNotifications[renoteIdx] = { + type: 'renote:grouped', + id: prevRenote.id, // this will be the newest id in this group + createdAt: prevRenote.createdAt, + noteId: prevRenote.noteId!, + userIds: [prevRenote.notifierId!], + }; + } + // add this new renote to the existing group + (prevRenote as FilterUnionByProperty).userIds.push(notification.notifierId!); + continue; + } + + // not a groupable notification, just push it groupedNotifications.push(notification); } - groupedNotifications = groupedNotifications.slice(0, ps.limit); - const noteIds = groupedNotifications - .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote', 'edited'].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); - } + // sort the groups by their id, newest first + groupedNotifications.sort( + (a, b) => a.id < b.id ? 1 : a.id > b.id ? -1 : 0, + ); 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 5e97b90f99..f5a48b2f69 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } 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'; @@ -69,7 +68,6 @@ export default class extends Endpoint { // eslint- private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, - private noteReadService: NoteReadService, ) { super(meta, paramDef, async (ps, me) => { // includeTypes が空の場合はクエリしない @@ -84,67 +82,19 @@ export default class extends Endpoint { // eslint- 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][]; - let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null; - let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null; - - let notifications: MiNotification[]; - for (;;) { - let notificationsRes: [id: string, fields: string[]][]; - - // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 - if (sinceTime && !untilTime) { - notificationsRes = await this.redisClient.xrange( - `notificationTimeline:${me.id}`, - '(' + sinceTime, - '+', - 'COUNT', ps.limit); - } else { - notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - untilTime ? '(' + untilTime : '+', - sinceTime ? '(' + sinceTime : '-', - 'COUNT', ps.limit); - } - - if (notificationsRes.length === 0) { - return []; - } - - notifications = notificationsRes.map(x => JSON.parse(x[1][1])) 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) { - // 通知が1件以上ある場合は返す - break; - } - - // フィルタしたことで通知が0件になった場合、次のページを取得する - if (ps.sinceId && !ps.untilId) { - sinceTime = notificationsRes[notificationsRes.length - 1][0]; - } else { - untilTime = notificationsRes[notificationsRes.length - 1][0]; - } - } + const notifications = await this.notificationService.getNotifications(me.id, { + sinceId: ps.sinceId, + untilId: ps.untilId, + limit: ps.limit, + includeTypes, + excludeTypes, + }); // Mark all as read if (ps.markAsRead) { this.notificationService.readAllNotification(me.id); } - const noteIds = notifications - .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote', 'edited'].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); }); } 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 deleted file mode 100644 index edf570fcc5..0000000000 --- a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * 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'; - -export const meta = { - tags: ['account'], - - requireCredential: true, - - kind: 'write:account', - - // 2 calls per second - limit: { - duration: 1000, - max: 2, - }, -} 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.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - - 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'); - }); - } -} 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 38328bb7d4..4fd6202604 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -3,13 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; 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 { generateNativeUserToken } from '@/misc/token.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -57,7 +56,7 @@ export default class extends Endpoint { // eslint- throw new Error('incorrect password'); } - const newToken = generateUserToken(); + const newToken = generateNativeUserToken(); await this.usersRepository.update(me.id, { token: newToken, 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 dff33016e0..d284334834 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -20,9 +20,7 @@ export const meta = { }, }, - res: { - type: 'object', - }, + res: {}, // 10 calls per 5 seconds limit: { 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 0be8bfb695..dc07556760 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -//import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index f74452e2af..5767880531 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -3,10 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import { JSDOM } from 'jsdom'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; @@ -31,8 +30,11 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { Config } from '@/config.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; +import { verifyFieldLinks } from '@/misc/verify-field-link.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { notificationRecieveConfig } from '@/models/json-schema/user.js'; +import { userUnsignedFetchOptions } from '@/const.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; @@ -214,6 +216,7 @@ export const paramDef = { autoSensitive: { type: 'boolean' }, followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, + chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] }, pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, mutedWords: muteWords, hardMutedWords: muteWords, @@ -235,6 +238,7 @@ export const paramDef = { receiveFollowRequest: notificationRecieveConfig, followRequestAccepted: notificationRecieveConfig, roleAssigned: notificationRecieveConfig, + chatRoomInvitationReceived: notificationRecieveConfig, achievementEarned: notificationRecieveConfig, app: notificationRecieveConfig, test: notificationRecieveConfig, @@ -255,6 +259,20 @@ export const paramDef = { enum: ['default', 'parent', 'defaultParent', 'parentDefault'], nullable: false, }, + allowUnsignedFetch: { + type: 'string', + enum: userUnsignedFetchOptions, + nullable: false, + }, + attributionDomains: { + type: 'array', + items: { + type: 'string', + minLength: 1, + maxLength: 128, + }, + maxItems: 32, + }, }, } as const; @@ -319,10 +337,16 @@ export default class extends Endpoint { // eslint- if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz; if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility; if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; + if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope; function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { - // TODO: ちゃんと数える - const length = JSON.stringify(mutedWords).length; + const length = mutedWords.reduce((sum, word) => { + const wordLength = Array.isArray(word) + ? word.reduce((l, w) => l + w.length, 0) + : word.length; + return sum + wordLength; + }, 0); + if (length > limit) { throw new ApiError(meta.errors.tooManyMutedWords); } @@ -359,6 +383,7 @@ export default class extends Endpoint { // eslint- } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; + if (ps.attributionDomains !== undefined) updates.attributionDomains = ps.attributionDomains; 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; @@ -492,7 +517,7 @@ export default class extends Endpoint { // eslint- // Retrieve the old account const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { - this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`); + this.apiLoggerService.logger.warn(`failed to resolve destination user: ${renderInlineError(e)}`); throw new ApiError(meta.errors.noSuchUser); }); if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself); @@ -519,6 +544,10 @@ export default class extends Endpoint { // eslint- profileUpdates.defaultCWPriority = ps.defaultCWPriority; } + if (ps.allowUnsignedFetch !== undefined) { + updates.allowUnsignedFetch = ps.allowUnsignedFetch; + } + //#region emojis/tags let emojis = [] as string[]; @@ -574,9 +603,11 @@ export default class extends Endpoint { // eslint- this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id }); } + const verified_links = await verifyFieldLinks(newFields, `${this.config.url}/@${user.username}`, this.httpRequestService); + await this.userProfilesRepository.update(user.id, { ...profileUpdates, - verifiedLinks: [], + verifiedLinks: verified_links, }); const iObj = await this.userEntityService.pack(user.id, user, { @@ -586,7 +617,7 @@ export default class extends Endpoint { // eslint- const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - this.cacheService.userProfileCache.set(user.id, updatedProfile); + await this.cacheService.userProfileCache.set(user.id, updatedProfile); // Publish meUpdated event this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); @@ -598,18 +629,15 @@ export default class extends Endpoint { // eslint- // フォロワーにUpdateを配信 if (this.userNeedsPublishing(user, updates) || this.profileNeedsPublishing(profile, updatedProfile)) { - 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); + this.accountUpdateService.publishToFollowers(user); } return iObj; }); } + // this function is superseded by '@/misc/verify-field-link.ts' + /* private async verifyLink(url: string, user: MiLocalUser) { if (!safeForSql(url)) return; @@ -641,11 +669,12 @@ export default class extends Endpoint { // eslint- // なにもしない } } + */ // these two methods need to be kept in sync with // `ApRendererService.renderPerson` private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial): boolean { - const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore']; + const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore', 'attributionDomains']; for (const field of basicFields) { if ((field in newUser) && oldUser[field] !== newUser[field]) { return true; diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts index d661eab364..f607a35515 100644 --- a/packages/backend/src/server/api/endpoints/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/invite/create.ts @@ -18,7 +18,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'write:invite-codes', errors: { diff --git a/packages/backend/src/server/api/endpoints/invite/delete.ts b/packages/backend/src/server/api/endpoints/invite/delete.ts index 408200164c..d15d400e9b 100644 --- a/packages/backend/src/server/api/endpoints/invite/delete.ts +++ b/packages/backend/src/server/api/endpoints/invite/delete.ts @@ -14,7 +14,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'write:invite-codes', errors: { diff --git a/packages/backend/src/server/api/endpoints/invite/limit.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts index 8d92c4957c..150f4de441 100644 --- a/packages/backend/src/server/api/endpoints/invite/limit.ts +++ b/packages/backend/src/server/api/endpoints/invite/limit.ts @@ -15,7 +15,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'read:invite-codes', res: { diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts index b63b41edd3..12e3873304 100644 --- a/packages/backend/src/server/api/endpoints/invite/list.ts +++ b/packages/backend/src/server/api/endpoints/invite/list.ts @@ -14,7 +14,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'read:invite-codes', res: { 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 c42df7ca80..f962bd49f1 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -7,6 +7,7 @@ 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 { NotificationService } from '@/core/NotificationService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DI } from '@/di-symbols.js'; @@ -56,6 +57,7 @@ export default class extends Endpoint { // eslint- private accessTokensRepository: AccessTokensRepository, private idService: IdService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { // Generate access token @@ -77,6 +79,9 @@ export default class extends Endpoint { // eslint- permission: ps.permission, }); + // アクセストークンが生成されたことを通知 + this.notificationService.createNotification(me.id, 'createToken', {}); + return { token: accessToken, }; diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index f6c37023e1..00a88521fd 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -64,7 +64,16 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateSilencedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } if (ps.local) { query.andWhere('note.userHost IS NULL'); @@ -75,7 +84,15 @@ export default class extends Endpoint { // eslint- } if (ps.renote !== undefined) { - query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); + if (ps.renote) { + this.queryService.andIsRenote(query, 'note'); + + if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + } else { + this.queryService.andIsNotRenote(query, 'note'); + } } if (ps.withFiles !== undefined) { @@ -91,7 +108,7 @@ export default class extends Endpoint { // eslint- // query.isBot = bot; //} - const notes = await query.limit(ps.limit).getMany(); + const notes = await query.getMany(); return await this.noteEntityService.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 index d36d1dfc15..84d6aa0dc7 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -1,6 +1,10 @@ +/* + * SPDX-FileCopyrightText: Marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import type { NotesRepository, MiMeta } from '@/models/_.js'; +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'; @@ -8,7 +12,6 @@ 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'; export const meta = { tags: ['notes'], @@ -56,9 +59,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.meta) - private serverSettings: MiMeta, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -66,7 +66,6 @@ export default class extends Endpoint { // eslint- 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); @@ -74,28 +73,33 @@ export default class extends Endpoint { // eslint- 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: this.serverSettings.bubbleInstances }) + .andWhere('note.userHost IS NOT NULL') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); + // This subquery mess teaches postgres how to use the right indexes. + // Using WHERE or ON conditions causes a fallback to full sequence scan, which times out. + // Important: don't use a query builder here or TypeORM will get confused and stop quoting column names! (known, unfixed bug apparently) + query + .leftJoin('(select "host" from "instance" where "isBubbled" = true)', 'bubbleInstance', '"bubbleInstance"."host" = "note"."userHost"') + .andWhere('"bubbleInstance" IS NOT NULL'); + this.queryService + .leftJoinInstance(query, 'note.userInstance', 'userInstance', '"userInstance"."host" = "bubbleInstance"."host"'); + + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } if (ps.withFiles) { @@ -104,29 +108,20 @@ export default class extends Endpoint { // eslint- 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 != \'{}\''); - })); - })); + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } //#endregion - let timeline = await query.limit(ps.limit).getMany(); + const timeline = await query.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) { + if (me) { + process.nextTick(() => { 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 e69ba9be7e..cf8b11ccb5 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -57,33 +57,31 @@ export default class extends Endpoint { // eslint- 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'); - })); - })); - } + qb.orWhere('note.replyId = :noteId'); + + if (ps.showQuotes) { + qb.orWhere(new Brackets(qbb => this.queryService + .andIsQuote(qbb, 'note') + .andWhere('note.renoteId = :noteId'), + )); + } })) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters({ noteId: ps.noteId }) + .limit(ps.limit); this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); if (me) { - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } - const notes = await query.limit(ps.limit).getMany(); + const notes = await query.getMany(); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts index 18d80e867b..545889a7ee 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.test.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts @@ -65,7 +65,7 @@ describe('api:notes/create', () => { test('0 characters cw', () => { expect(v({ text: 'Body', cw: '' })) - .toBe(INVALID); + .toBe(VALID); }); test('reject only cw', () => { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index b0f32bfda8..461910543f 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -159,7 +159,7 @@ export const paramDef = { visibleUserIds: { type: 'array', uniqueItems: true, items: { type: 'string', format: 'misskey:id', } }, - cw: { type: 'string', nullable: true, minLength: 1 }, + cw: { type: 'string', nullable: true }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, @@ -348,7 +348,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchReplyTarget); } else if (isRenote(reply) && !isQuote(reply)) { throw new ApiError(meta.errors.cannotReplyToPureRenote); - } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { + } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) { throw new ApiError(meta.errors.cannotReplyToInvisibleNote); } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); @@ -400,7 +400,7 @@ export default class extends Endpoint { // eslint- text: ps.text ?? undefined, reply, renote, - cw: ps.cw, + cw: ps.cw || null, localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index cc2293c5d6..bd70cb7835 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -31,13 +31,11 @@ export const meta = { res: { type: 'object', - optional: false, - nullable: false, + optional: false, nullable: false, properties: { createdNote: { type: 'object', - optional: false, - nullable: false, + optional: false, nullable: false, ref: 'Note', }, }, @@ -209,7 +207,7 @@ export const paramDef = { format: 'misskey:id', }, }, - cw: { type: 'string', nullable: true, minLength: 1 }, + cw: { type: 'string', nullable: true }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, @@ -404,7 +402,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchReplyTarget); } else if (isRenote(reply) && !isQuote(reply)) { throw new ApiError(meta.errors.cannotReplyToPureRenote); - } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { + } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) { throw new ApiError(meta.errors.cannotReplyToInvisibleNote); } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); @@ -454,7 +452,7 @@ export default class extends Endpoint { // eslint- text: ps.text ?? undefined, reply, renote, - cw: ps.cw, + cw: ps.cw || null, localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 4853489827..8ab9f72139 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -11,6 +11,9 @@ 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 { QueryService } from '@/core/QueryService.js'; +import { ApiError } from '@/server/api/error.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['notes'], @@ -29,10 +32,19 @@ export const meta = { }, }, - // 10 calls per 5 seconds + errors: { + ltlDisabled: { + message: 'Local timeline has been disabled.', + code: 'LTL_DISABLED', + id: '45a6eb02-7695-4393-b023-dd3be9aaaefd', + }, + }, + + // Burst of 10 calls to handle tab reload, then 4/second for refresh limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 10, + dripSize: 4, }, } as const; @@ -58,8 +70,15 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private noteEntityService: NoteEntityService, private featuredService: FeaturedService, + private queryService: QueryService, + private readonly roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me ? me.id : null); + if (!policies.ltlAvailable) { + throw new ApiError(meta.errors.ltlDisabled); + } + let noteIds: string[]; if (ps.channelId) { noteIds = await this.featuredService.getInChannelNotesRanking(ps.channelId, 50); @@ -98,7 +117,10 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('note.channel', 'channel') + .andWhere('user.isExplorable = TRUE'); + + this.queryService.generateBlockedHostQueryForNote(query); const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 228793fbf6..0f8c61ab3e 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { IsNull, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { SkLatestNote, MiFollowing } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { QueryService } from '@/core/QueryService.js'; import { ApiError } from '@/server/api/error.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; export const meta = { tags: ['notes'], @@ -76,8 +77,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, - private queryService: QueryService, + private readonly noteEntityService: NoteEntityService, + private readonly queryService: QueryService, + private readonly activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { if (ps.includeReplies && ps.filesOnly) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); @@ -85,7 +87,7 @@ export default class extends Endpoint { // eslint- const query = this.notesRepository .createQueryBuilder('note') - .setParameter('me', me.id) + .setParameters({ meId: me.id }) // Limit to latest notes .innerJoin( @@ -130,7 +132,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel') + + // Exclude channel notes + .andWhere({ channelId: IsNull() }) ; // Limit to files, if requested @@ -143,19 +147,28 @@ export default class extends Endpoint { // eslint- query.andWhere('"user"."isBot" = false'); } - // Respect blocks and mutes - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); + // Hide blocked users / instances + query.andWhere('"user"."isSuspended" = false'); + this.queryService.generateBlockedHostQueryForNote(query); + + // Respect blocks, mutes, and privacy + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); // Support pagination this.queryService .makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .orderBy('note.id', 'DESC') .take(ps.limit); // Query and return the next page const notes = await query.getMany(); - return await this.noteEntityService.packMany(notes, me); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(notes, me, { skipHide: true }); }); } } @@ -164,14 +177,14 @@ export default class extends Endpoint { // eslint- * Limit to followers (they follow us) */ function addFollower>(query: T): T { - return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :me'); + return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :meId'); } /** * Limit to followees (we follow them) */ function addFollowee>(query: T): T { - return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :me AND followee."followeeId" = latest.user_id'); + return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :meId AND followee."followeeId" = latest.user_id'); } /** 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 0f2592bd78..506ea6fcda 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -12,7 +12,6 @@ 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 { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -68,7 +67,6 @@ export default class extends Endpoint { // eslint- 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); @@ -76,12 +74,6 @@ export default class extends Endpoint { // eslint- 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) @@ -93,10 +85,11 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } if (ps.withFiles) { @@ -105,29 +98,20 @@ export default class extends Endpoint { // eslint- 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 != \'{}\''); - })); - })); + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } //#endregion - let timeline = await query.limit(ps.limit).getMany(); + const 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) { + if (me) { + process.nextTick(() => { this.activeUsersChart.read(me); - } - }); + }); + } return await this.noteEntityService.packMany(timeline, me); }); 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 3c66154e19..a5623d1f03 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -66,9 +66,6 @@ export const paramDef = { 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 }, @@ -114,12 +111,10 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withReplies: ps.withReplies, withBots: ps.withBots, + withRenotes: ps.withRenotes, }, me); process.nextTick(() => { @@ -169,7 +164,7 @@ export default class extends Endpoint { // eslint- excludeBots: !ps.withBots, noteFilter: note => { if (note.reply && note.reply.visibility === 'followers') { - if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; + if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false; } return true; @@ -178,12 +173,10 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withReplies: ps.withReplies, withBots: ps.withBots, + withRenotes: ps.withRenotes, }, me), }); @@ -199,102 +192,58 @@ export default class extends Endpoint { // eslint- untilId: string | null, sinceId: string | null, limit: number, - includeMyRenotes: boolean, - includeRenotedMyNotes: boolean, - includeLocalRenotes: boolean, withFiles: boolean, withReplies: boolean, withBots: boolean, + withRenotes: boolean, }, me: MiLocalUser) { - const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.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)'); - } - })) + // 1. by a user I follow, 2. a public local post, 3. my own post + .andWhere(new Brackets(qb => this.queryService + .orFollowingUser(qb, ':meId', 'note.userId') + .orWhere(new Brackets(qbb => qbb + .andWhere('note.visibility = \'public\'') + .andWhere('note.userHost IS NULL'))) + .orWhere(':meId = note.userId'))) + // 1. in a channel I follow, 2. not in a channel + .andWhere(new Brackets(qb => this.queryService + .orFollowingChannel(qb, ':meId', 'note.channelId') + .orWhere('note.channelId IS NULL'))) + .setParameters({ meId: me.id }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (followingChannels.length > 0) { - const followingChannelIds = followingChannels.map(x => x.followeeId); - - 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'); - } + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); 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'); - })); - })); + query + // 1. Not a reply, 2. a self-reply + .andWhere(new Brackets(qb => qb + .orWhere('note.replyId IS NULL') // 返信ではない + .orWhere('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)'); - })); - } + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } if (!ps.withBots) query.andWhere('user.isBot = FALSE'); + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } //#endregion - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } 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 1f986079c2..41b1ee1086 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -103,13 +103,14 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withReplies: ps.withReplies, withBots: ps.withBots, + withRenotes: ps.withRenotes, }, me); - process.nextTick(() => { - if (me) { + if (me) { + process.nextTick(() => { this.activeUsersChart.read(me); - } - }); + }); + } return await this.noteEntityService.packMany(timeline, me); } @@ -136,14 +137,15 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withReplies: ps.withReplies, withBots: ps.withBots, + withRenotes: ps.withRenotes, }, me), }); - process.nextTick(() => { - if (me) { + if (me) { + process.nextTick(() => { this.activeUsersChart.read(me); - } - }); + }); + } return timeline; }); @@ -156,39 +158,47 @@ export default class extends Endpoint { // eslint- withFiles: boolean, withReplies: boolean, withBots: boolean, + withRenotes: 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)') + .andWhere('note.visibility = \'public\'') + .andWhere('note.channelId IS NULL') + .andWhere('note.userHost IS NULL') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); - 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); + if (!ps.withReplies) { + query + // 1. Not a reply, 2. a self-reply + .andWhere(new Brackets(qb => qb + .orWhere('note.replyId IS NULL') // 返信ではない + .orWhere('note.replyUserId = note.userId'))); + } + + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } 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.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + + return await query.getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 38912421a4..f30e5a583f 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -6,11 +6,12 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; +import { MiNote } 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 ActiveUsersChart from '@/core/chart/charts/active-users.js'; export const meta = { tags: ['notes'], @@ -58,44 +59,58 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, - private noteReadService: NoteReadService, + private readonly activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { - qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる - .where(':meIdAsList <@ note.mentions') - .orWhere(':meIdAsList <@ note.visibleUserIds'); - })) - // Avoid scanning primary key index - .orderBy('CONCAT(note.id)', 'DESC') + .innerJoin(qb => { + qb + .select('note.id', 'id') + .from(qbb => qbb + .select('note.id', 'id') + .from(MiNote, 'note') + .where(new Brackets(qbbb => qbbb + // DM to me + .orWhere(':meIdAsList <@ note.visibleUserIds') + // Mentions me + .orWhere(':meIdAsList <@ note.mentions'), + )) + .setParameters({ meIdAsList: [me.id] }) + , 'source') + .innerJoin(MiNote, 'note', 'note.id = source.id'); + + this.queryService.generateVisibilityQuery(qb, me); + this.queryService.generateBlockedHostQueryForNote(qb); + this.queryService.generateMutedUserQueryForNotes(qb, me); + this.queryService.generateMutedNoteThreadQuery(qb, me); + this.queryService.generateBlockedUserQueryForNotes(qb, me); + // A renote can't mention a user, so it will never appear here anyway. + //this.queryService.generateMutedUserRenotesQueryForNotes(qb, me); + + if (ps.visibility) { + qb.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); + } + + if (ps.following) { + this.queryService + .andFollowingUser(qb, ':meId', 'note.userId') + .setParameters({ meId: me.id }); + } + + return qb; + }, 'source', 'source.id = note.id') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteThreadQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + const mentions = await query.getMany(); - 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); + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return await this.noteEntityService.packMany(mentions, me); }); 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 33a9c281b3..6f96821a63 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -9,13 +9,13 @@ import type { NotesRepository, MutingsRepository, PollsRepository, PollVotesRepo import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '@/server/api/error.js'; export const meta = { tags: ['notes'], - requireCredential: true, - kind: 'read:account', - res: { type: 'array', optional: false, nullable: false, @@ -26,10 +26,24 @@ export const meta = { }, }, - // 2 calls per second + errors: { + ltlDisabled: { + message: 'Local timeline has been disabled.', + code: 'LTL_DISABLED', + id: '45a6eb02-7695-4393-b023-dd3be9aaaefd', + }, + gtlDisabled: { + message: 'Global timeline has been disabled.', + code: 'GTL_DISABLED', + id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b', + }, + }, + + // Up to 10 calls, then 2 per second limit: { - duration: 1000, - max: 2, + type: 'bucket', + size: 10, + dripRate: 500, }, } as const; @@ -39,6 +53,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, excludeChannels: { type: 'boolean', default: false }, + local: { type: 'boolean', nullable: true, default: null }, + expired: { type: 'boolean', default: false }, }, required: [], } as const; @@ -59,18 +75,54 @@ export default class extends Endpoint { // eslint- private mutingsRepository: MutingsRepository, private noteEntityService: NoteEntityService, + private readonly queryService: QueryService, + private readonly roleService: RoleService, ) { 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 => { + .innerJoinAndSelect('poll.note', 'note') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('reply.user', 'replyUser') + .andWhere('user.isExplorable = TRUE') + ; + + if (me) { + query.andWhere('poll.userId != :meId', { meId: me.id }); + } + + if (ps.expired) { + query.andWhere('poll.expiresAt IS NOT NULL'); + query.andWhere('poll.expiresAt <= :expiresMax', { + expiresMax: new Date(), + }); + query.andWhere('poll.expiresAt >= :expiresMin', { + expiresMin: new Date(Date.now() - (1000 * 60 * 60 * 24 * 7)), + }); + } else { + query.andWhere(new Brackets(qb => { qb .where('poll.expiresAt IS NULL') .orWhere('poll.expiresAt > :now', { now: new Date() }); })); + } + const policies = await this.roleService.getUserPolicies(me?.id ?? null); + if (ps.local != null) { + if (ps.local) { + if (!policies.ltlAvailable) throw new ApiError(meta.errors.ltlDisabled); + query.andWhere('poll.userHost IS NULL'); + } else { + if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled); + query.andWhere('poll.userHost IS NOT NULL'); + } + } else { + if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled); + } + + /* //#region exclude arleady voted polls const votedQuery = this.pollVotesRepository.createQueryBuilder('vote') .select('vote.noteId') @@ -81,16 +133,15 @@ export default class extends Endpoint { // eslint- query.setParameters(votedQuery.getParameters()); //#endregion + */ - //#region mute - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - query - .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`); - - query.setParameters(mutingQuery.getParameters()); + //#region block/mute/vis + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + } //#endregion //#region exclude channels @@ -107,6 +158,7 @@ export default class extends Endpoint { // eslint- if (polls.length === 0) return []; + /* const notes = await this.notesRepository.find({ where: { id: In(polls.map(poll => poll.noteId)), @@ -115,6 +167,10 @@ export default class extends Endpoint { // eslint- id: 'DESC', }, }); + */ + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const notes = polls.map(poll => poll.note!); return await this.noteEntityService.packMany(notes, me, { 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 a5014a490f..0b318304f3 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -174,7 +174,7 @@ export default class extends Endpoint { // eslint- } // リモートフォロワーにUpdate配信 - this.pollService.deliverQuestionUpdate(note.id); + this.pollService.deliverQuestionUpdate(note); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index e683cc87bd..f2355518a2 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -80,8 +80,8 @@ export default class extends Endpoint { // eslint- query.andWhere('reaction.reaction = :type', { type }); } - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); const reactions = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 15f114266a..be7cb0320f 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -47,7 +47,7 @@ export const paramDef = { type: 'object', properties: { noteId: { type: 'string', format: 'misskey:id' }, - userId: { 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' }, @@ -81,18 +81,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.userId) { - query.andWhere("user.id = :userId", { userId: ps.userId }); + query.andWhere('user.id = :userId', { userId: ps.userId }); } if (ps.quote) { - query.andWhere("note.text IS NOT NULL"); + this.queryService.andIsQuote(query, 'note'); } else { - query.andWhere("note.text IS NULL"); + this.queryService.andIsRenote(query, 'note'); } this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } const renotes = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 3f0a8157c4..f79bfaa7df 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -59,13 +59,17 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } - const timeline = await query.limit(ps.limit).getMany(); + const timeline = await query.getMany(); return await this.noteEntityService.packMany(timeline, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts index 4dd3d7a81a..cbf3a961c0 100644 --- a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -100,7 +100,7 @@ export default class extends Endpoint { // eslint- id: string; note: { text?: string; - cw?: string|null; + cw?: string | null; fileIds: string[]; visibility: typeof noteVisibilities[number]; visibleUsers: Packed<'UserLite'>[]; @@ -133,6 +133,7 @@ export default class extends Endpoint { // eslint- renote, reply, renoteId: item.note.renote, replyId: item.note.reply, + poll: item.note.poll ? await this.fillPoll(item.note.poll) : undefined, }, }; })); @@ -155,4 +156,21 @@ export default class extends Endpoint { // eslint- } return null; } + + // Pulled from NoteEntityService and modified to work with MiNoteSchedule + // originally planned to use directly from NoteEntityService but since the poll doesn't actually exist yet that doesn't work + @bindThis + private async fillPoll(poll: { multiple: boolean; choices: string[]; expiresAt: string | null }) { + const choices = poll.choices.map(c => ({ + text: c, + votes: 0, // Default to 0 as there will never be any registered votes while scheduled + isVoted: false, // Default to false as the author can't vote anyways since the poll does not exist in the repo yet + })); + + return { + multiple: poll.multiple, + expiresAt: poll.expiresAt ? new Date(poll.expiresAt).toISOString() : null, + choices, + }; + } } 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 6bba7bf37e..5064144d9c 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 @@ -12,8 +12,6 @@ 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 { CacheService } from '@/core/CacheService.js'; -import { UtilityService } from '@/core/UtilityService.js'; export const meta = { tags: ['notes', 'hashtags'], @@ -82,30 +80,27 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, - private cacheService: CacheService, - private utilityService: UtilityService, ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere("note.visibility IN ('public', 'home')") // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()` + .andWhere(new Brackets(qb => qb + .orWhere('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''))) // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()` .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); + + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (!this.serverSettings.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'); @@ -137,9 +132,9 @@ export default class extends Endpoint { // eslint- if (ps.renote != null) { if (ps.renote) { - query.andWhere('note.renoteId IS NOT NULL'); + this.queryService.andIsRenote(query, 'note'); } else { - query.andWhere('note.renoteId IS NULL'); + this.queryService.andIsNotRenote(query, 'note'); } } @@ -156,17 +151,7 @@ export default class extends Endpoint { // eslint- } // 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 (note.userHost) { - if (!this.utilityService.isFederationAllowedHost(note.userHost)) return false; - if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false; - } - return true; - }); + const notes = await query.getMany(); return await this.noteEntityService.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 f0c9db38b4..44e7137f29 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -67,7 +67,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } const note = await query.getOne(); 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 732d644a29..29c6aa7434 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 @@ -9,7 +9,6 @@ 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 { ApiError } from '../../../error.js'; @@ -52,7 +51,6 @@ export default class extends Endpoint { // eslint- private noteThreadMutingsRepository: NoteThreadMutingsRepository, private getterService: GetterService, - private noteReadService: NoteReadService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { @@ -69,8 +67,6 @@ export default class extends Endpoint { // eslint- }], }); - await this.noteReadService.read(me.id, mutedNotes); - await this.noteThreadMutingsRepository.insert({ id: this.idService.gen(), threadId: note.threadId ?? note.id, diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 5a46f66f9e..44c539eaad 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -49,9 +49,6 @@ export const paramDef = { 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 }, @@ -88,9 +85,6 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, withBots: ps.withBots, @@ -121,7 +115,7 @@ export default class extends Endpoint { // eslint- excludePureRenotes: !ps.withRenotes, noteFilter: note => { if (note.reply && note.reply.visibility === 'followers') { - if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; + if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false; } if (!ps.withBots && note.user?.isBot) return false; @@ -131,9 +125,6 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, withBots: ps.withBots, @@ -148,112 +139,48 @@ export default class extends Endpoint { // eslint- }); } - 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, - }, - }); - + private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) { //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + // 1. in a channel I follow, 2. my own post, 3. by a user I follow + .andWhere(new Brackets(qb => this.queryService + .orFollowingChannel(qb, ':meId', 'note.channelId') + .orWhere(':meId = note.userId') + .orWhere(new Brackets(qb2 => this.queryService + .andFollowingUser(qb2, ':meId', 'note.userId') + .andWhere('note.channelId IS NULL'))), + )) + // 1. Not a reply, 2. a self-reply + .andWhere(new Brackets(qb => qb + .orWhere('note.replyId IS NULL') // 返信ではない + .orWhere('note.replyUserId = note.userId'))) + .setParameters({ meId: me.id }) .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 }); - } - - 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'); - })); - })); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); 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)'); - })); - } + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); 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'); + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } //#endregion - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 61a511510c..5ebd5ef362 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -10,22 +10,26 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; -import { MiMeta } from '@/models/_.js'; +import type { MiMeta, MiNote } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { hasText } from '@/models/Note.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], - requireCredential: true, + requireCredential: 'optional', kind: 'read:account', + requiredRolePolicy: 'canUseTranslator', res: { type: 'object', - optional: true, nullable: false, + optional: false, nullable: false, properties: { - sourceLang: { type: 'string' }, - text: { type: 'string' }, + sourceLang: { type: 'string', optional: true, nullable: false }, + text: { type: 'string', optional: true, nullable: false }, }, }, @@ -45,6 +49,11 @@ export const meta = { code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE', id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d', }, + translationFailed: { + message: 'Failed to translate note. Please try again later or contact an administrator for assistance.', + code: 'TRANSLATION_FAILED', + id: '4e7a1a4f-521c-4ba2-b10a-69e5e2987b2f', + }, }, // 10 calls per 5 seconds @@ -73,72 +82,123 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, private httpRequestService: HttpRequestService, private roleService: RoleService, + private readonly cacheService: CacheService, + private readonly loggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me) => { - const policies = await this.roleService.getUserPolicies(me.id); - if (!policies.canUseTranslator) { - throw new ApiError(meta.errors.unavailable); - } - 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))) { + if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null, { me }))) { throw new ApiError(meta.errors.cannotTranslateInvisibleNote); } - if (note.text == null) { - return; + if (!hasText(note)) { + return {}; } - if (this.serverSettings.deeplAuthKey == null && !this.serverSettings.deeplFreeMode) { - throw new ApiError(meta.errors.unavailable); - } - - if (this.serverSettings.deeplFreeMode && !this.serverSettings.deeplFreeInstance) { - throw new ApiError(meta.errors.unavailable); - } + const canDeeplFree = this.serverSettings.deeplFreeMode && !!this.serverSettings.deeplFreeInstance; + const canDeepl = !!this.serverSettings.deeplAuthKey || canDeeplFree; + const canLibre = !!this.serverSettings.libreTranslateURL; + if (!canDeepl && !canLibre) throw new ApiError(meta.errors.unavailable); let targetLang = ps.targetLang; if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; - const params = new URLSearchParams(); - if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey); - params.append('text', note.text); - params.append('target_lang', targetLang); + let response = await this.cacheService.getCachedTranslation(note, targetLang); + if (!response) { + this.loggerService.logger.debug(`Fetching new translation for note=${note.id} lang=${targetLang}`); + response = await this.fetchTranslation(note, targetLang); + if (!response) { + throw new ApiError(meta.errors.translationFailed); + } - const endpoint = this.serverSettings.deeplFreeMode && this.serverSettings.deeplFreeInstance ? this.serverSettings.deeplFreeInstance : this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + await this.cacheService.setCachedTranslation(note, targetLang, response); + } + return response; + }); + } + + private async fetchTranslation(note: MiNote & { text: string }, targetLang: string) { + // Load-bearing try/catch - removing this will shift indentation and cause ~80 lines of upstream merge conflicts + try { + // Ignore deeplFreeInstance unless deeplFreeMode is set + const deeplFreeInstance = this.serverSettings.deeplFreeMode ? this.serverSettings.deeplFreeInstance : null; + + // DeepL/DeepLX handling + if (this.serverSettings.deeplAuthKey || deeplFreeInstance) { + const params = new URLSearchParams(); + if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey); + params.append('text', note.text); + params.append('target_lang', targetLang); + const endpoint = deeplFreeInstance ?? ( this.serverSettings.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(), + timeout: this.serverSettings.translationTimeout, + }); + if (this.serverSettings.deeplAuthKey) { + 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, + }; + } else { + const json = (await res.json()) as { + code: number, + message: string, + data: string, + source_lang: string, + target_lang: string, + alternatives: string[], + }; + + const languageNames = new Intl.DisplayNames(['en'], { + type: 'language', + }); + + return { + sourceLang: languageNames.of(json.source_lang), + text: json.data, + }; + } + } + + // LibreTranslate handling + if (this.serverSettings.libreTranslateURL) { + const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, */*', + }, + body: JSON.stringify({ + q: note.text, + source: 'auto', + target: targetLang, + format: 'text', + api_key: this.serverSettings.libreTranslateKey ?? '', + }), + timeout: this.serverSettings.translationTimeout, + }); - const res = await this.httpRequestService.send(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json, */*', - }, - body: params.toString(), - }); - if (this.serverSettings.deeplAuthKey) { 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, - }; - } else { - const json = (await res.json()) as { - code: number, - message: string, - data: string, - source_lang: string, - target_lang: string, alternatives: string[], + detectedLanguage: { [key: string]: string | number }, + translatedText: string, }; const languageNames = new Intl.DisplayNames(['en'], { @@ -146,10 +206,14 @@ export default class extends Endpoint { // eslint- }); return { - sourceLang: languageNames.of(json.source_lang), - text: json.data, + sourceLang: languageNames.of(json.detectedLanguage.language as string), + text: json.translatedText, }; } - }); + } catch (e) { + this.loggerService.logger.error('Unhandled error from translation API: ', { e }); + } + + return null; } } diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts index 58932bd83a..f2a927f3c5 100644 --- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -66,6 +66,7 @@ export default class extends Endpoint { // eslint- renoteId: note.id, }); + // TODO inline this into the above query for (const note of renotes) { if (ps.quote) { if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false); 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 55cda135e2..0f038e5541 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 @@ -57,9 +57,6 @@ export const paramDef = { 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', @@ -109,14 +106,13 @@ export default class extends Endpoint { // eslint- 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); + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return await this.noteEntityService.packMany(timeline, me); } @@ -135,15 +131,14 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, }, me), }); - this.activeUsersChart.read(me); + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return timeline; }); @@ -153,92 +148,49 @@ export default class extends Endpoint { // eslint- 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') + .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) + .andWhere('note.channelId IS NULL') // チャンネルノートではない + .andWhere(new Brackets(qb => qb + // 返信ではない + .orWhere('note.replyId IS NULL') + // 返信だけど投稿者自身への返信 + .orWhere('note.replyUserId = note.userId') + // 返信だけど自分宛ての返信 + .orWhere('note.replyUserId = :meId') + // 返信だけどwithRepliesがtrueの場合 + .orWhere('userListMemberships.withReplies = true'), + )) + .setParameters({ meId: me.id }) .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'); - })); - })); + .limit(ps.limit); 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.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 != \'{}\''); - })); - })); - } + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + //#endregion - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/notes/versions.ts b/packages/backend/src/server/api/endpoints/notes/versions.ts index 9b98d19fb1..1c6f9838f5 100644 --- a/packages/backend/src/server/api/endpoints/notes/versions.ts +++ b/packages/backend/src/server/api/endpoints/notes/versions.ts @@ -17,8 +17,25 @@ export const meta = { requireCredential: false, res: { - type: 'object', - optional: false, nullable: false, + type: 'array', + items: { + type: 'object', + optional: false, nullable: false, + properties: { + oldDate: { + type: 'string', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, }, errors: { @@ -60,13 +77,13 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = await this.notesRepository.createQueryBuilder('note') + const query = this.notesRepository.createQueryBuilder('note') .where('note.id = :noteId', { noteId: ps.noteId }) .innerJoinAndSelect('note.user', 'user'); this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } const note = await query.getOne(); @@ -75,6 +92,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchNote); } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (note.user!.requireSigninToViewContents && me == null) { throw new ApiError(meta.errors.signinRequired); } @@ -84,17 +102,17 @@ export default class extends Endpoint { // eslint- throw err; }); - let editArray = []; + let editArray: { oldDate: string, updatedAt: string, text: string | null }[] = []; for (const edit of edits) { editArray.push({ - oldDate: edit.oldDate as Date | null ?? null, - updatedAt: edit.updatedAt, + oldDate: (edit.oldDate ?? edit.updatedAt).toISOString(), + updatedAt: edit.updatedAt.toISOString(), 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(); }); + editArray = editArray.sort((a, b) => { return new Date(b.oldDate).getTime() - new Date(a.oldDate).getTime(); }); return editArray; }); diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index bf40e1e3e0..fe23160bb8 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -6,9 +6,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import * as Redis from 'ioredis'; +import { LoggerService } from '@/core/LoggerService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { resetDb } from '@/misc/reset-db.js'; +import { MetaService } from '@/core/MetaService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; export const meta = { tags: ['non-productive'], @@ -42,13 +45,27 @@ export default class extends Endpoint { // eslint- @Inject(DI.redis) private redisClient: Redis.Redis, + + private loggerService: LoggerService, + private metaService: MetaService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test'); - await redisClient.flushdb(); + const logger = this.loggerService.getLogger('reset-db'); + logger.info('---- Resetting database...'); + + await this.redisClient.flushdb(); await resetDb(this.db); + // DIコンテナで管理しているmetaのインスタンスには上記のリセット処理が届かないため、 + // 初期値を流して明示的にリフレッシュする + const meta = await this.metaService.fetch(true); + this.globalEventService.publishInternalEvent('metaUpdated', { after: meta }); + + logger.info('---- Database reset complete.'); + 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 d9240dec5e..ba0c60f4ec 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -3,7 +3,6 @@ * 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'; @@ -60,7 +59,6 @@ export default class extends Endpoint { // eslint- } // Generate hash of password - //const salt = await bcrypt.genSalt(8); const hash = await argon2.hash(ps.password); await this.userProfilesRepository.update(req.userId, { diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index b3c73e0391..741bd819ba 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -12,6 +12,7 @@ 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 ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -74,6 +75,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, private fanoutTimelineService: FanoutTimelineService, + private readonly activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -101,18 +103,24 @@ export default class extends Endpoint { // eslint- const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .andWhere('(note.visibility = \'public\')') + .orderBy('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.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); const notes = await query.getMany(); - notes.sort((a, b) => a.id > b.id ? -1 : 1); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index 528de76707..33ef48226d 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -66,7 +66,8 @@ export const meta = { // 24 calls, then 7 per second-ish (1 for each type of server info graph) limit: { - max: 24, + type: 'bucket', + size: 24, dripSize: 7, dripRate: 900, }, diff --git a/packages/backend/src/server/api/endpoints/sponsors.ts b/packages/backend/src/server/api/endpoints/sponsors.ts index 401d9292bc..8e1ac749d5 100644 --- a/packages/backend/src/server/api/endpoints/sponsors.ts +++ b/packages/backend/src/server/api/endpoints/sponsors.ts @@ -14,6 +14,39 @@ export const meta = { requireCredential: false, requireCredentialPrivateMode: false, + res: { + type: 'object', + nullable: false, optional: false, + properties: { + sponsor_data: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + name: { + type: 'string', + nullable: false, optional: false, + }, + image: { + type: 'string', + nullable: true, optional: false, + }, + website: { + type: 'string', + nullable: true, optional: false, + }, + profile: { + type: 'string', + nullable: false, optional: false, + }, + }, + }, + }, + }, + }, + // 2 calls per second limit: { duration: 1000, @@ -24,6 +57,7 @@ export const meta = { export const paramDef = { type: 'object', properties: { + // TODO remove this or make staff-only to prevent DoS forceUpdate: { type: 'boolean', default: false }, instance: { type: 'boolean', default: false }, }, @@ -35,12 +69,19 @@ export default class extends Endpoint { // eslint- constructor( private sponsorsService: SponsorsService, ) { - super(meta, paramDef, async (ps, me) => { - if (ps.instance) { - return { sponsor_data: await this.sponsorsService.instanceSponsors(ps.forceUpdate) }; - } else { - return { sponsor_data: await this.sponsorsService.sharkeySponsors(ps.forceUpdate) }; - } + super(meta, paramDef, async (ps) => { + const sponsors = ps.instance + ? await this.sponsorsService.instanceSponsors(ps.forceUpdate) + : await this.sponsorsService.sharkeySponsors(ps.forceUpdate); + + return { + sponsor_data: sponsors.map(s => ({ + name: s.name, + image: s.image, + website: s.website, + profile: s.profile, + })), + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts index f447b5598b..2f72e6ce1d 100644 --- a/packages/backend/src/server/api/endpoints/sw/register.ts +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -104,7 +104,7 @@ export default class extends Endpoint { // eslint- sendReadMessage: ps.sendReadMessage, }); - this.pushNotificationService.refreshCache(me.id); + await this.pushNotificationService.refreshCache(me.id); return { state: 'subscribed' as const, diff --git a/packages/backend/src/server/api/endpoints/sw/unregister.ts b/packages/backend/src/server/api/endpoints/sw/unregister.ts index aa7e03dceb..f43a2cce28 100644 --- a/packages/backend/src/server/api/endpoints/sw/unregister.ts +++ b/packages/backend/src/server/api/endpoints/sw/unregister.ts @@ -46,7 +46,7 @@ export default class extends Endpoint { // eslint- }); if (me) { - this.pushNotificationService.refreshCache(me.id); + await this.pushNotificationService.refreshCache(me.id); } }); } diff --git a/packages/backend/src/server/api/endpoints/sw/update-registration.ts b/packages/backend/src/server/api/endpoints/sw/update-registration.ts index 78b9323b7b..0cbed273e8 100644 --- a/packages/backend/src/server/api/endpoints/sw/update-registration.ts +++ b/packages/backend/src/server/api/endpoints/sw/update-registration.ts @@ -86,7 +86,7 @@ export default class extends Endpoint { // eslint- sendReadMessage: swSubscription.sendReadMessage, }); - this.pushNotificationService.refreshCache(me.id); + await this.pushNotificationService.refreshCache(me.id); return { userId: swSubscription.userId, diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index 089d346cd2..defd38fe96 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -4,11 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import { MiFollowing } from '@/models/_.js'; +import type { MiUser, 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 { RoleService } from '@/core/RoleService.js'; +import type { SelectQueryBuilder } from 'typeorm'; export const meta = { tags: ['users'], @@ -25,10 +28,11 @@ export const meta = { }, }, - // 2 calls per second + // 20 calls, then 4 per second limit: { - duration: 1000, - max: 2, + type: 'bucket', + size: 20, + dripRate: 250, }, } as const; @@ -37,7 +41,7 @@ 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'] }, + sort: { type: 'string', enum: ['+follower', '-follower', '+localFollower', '-localFollower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, hostname: { @@ -58,6 +62,7 @@ export default class extends Endpoint { // eslint- private userEntityService: UserEntityService, private queryService: QueryService, + private readonly roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const query = this.usersRepository.createQueryBuilder('user') @@ -80,6 +85,8 @@ export default class extends Endpoint { // eslint- switch (ps.sort) { case '+follower': query.orderBy('user.followersCount', 'DESC'); break; case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'DESC'); break; + case '-localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', '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; @@ -93,9 +100,29 @@ export default class extends Endpoint { // eslint- query.limit(ps.limit); query.offset(ps.offset); - const users = await query.getMany(); + const allUsers = await query.getMany(); + + // This is not ideal, for a couple of reasons: + // 1. It may return less than "limit" results. + // 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early. + // Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB. + const usersWithRoles = await Promise.all(allUsers.map(async u => [u, await this.roleService.getUserPolicies(u)] as const)); + const users = usersWithRoles + .filter(([,p]) => p.canTrend) + .map(([u]) => u); return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); } + + private addLocalFollowers(query: SelectQueryBuilder) { + query.innerJoin(qb => { + return qb + .from(MiFollowing, 'f') + .addSelect('f."followeeId"') + .addSelect('COUNT(*) FILTER (where f."followerHost" IS NULL)', 'localFollowers') + .addSelect('COUNT(*) FILTER (where f."followeeHost" IS NOT NULL)', 'remoteFollowers') + .groupBy('"followeeId"'); + }, 'f', 'user.id = f."followeeId"'); + } } diff --git a/packages/backend/src/server/api/endpoints/users/featured-notes.ts b/packages/backend/src/server/api/endpoints/users/featured-notes.ts index e6acae08b1..3fb091cc0e 100644 --- a/packages/backend/src/server/api/endpoints/users/featured-notes.ts +++ b/packages/backend/src/server/api/endpoints/users/featured-notes.ts @@ -11,6 +11,7 @@ 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'; +import { QueryService } from '@/core/QueryService.js'; export const meta = { tags: ['notes'], @@ -55,6 +56,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private featuredService: FeaturedService, private cacheService: CacheService, + private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set(); @@ -91,6 +93,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); + this.queryService.generateBlockedHostQueryForNote(query); + const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false; if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false; diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index c1617e14e5..82ce282bfc 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -12,6 +12,7 @@ import { FollowingEntityService } from '@/core/entities/FollowingEntityService.j import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -89,6 +90,7 @@ export default class extends Endpoint { // eslint- private followingEntityService: FollowingEntityService, private queryService: QueryService, private roleService: RoleService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy(ps.userId != null @@ -110,12 +112,7 @@ export default class extends Endpoint { // eslint- if (me == null) { throw new ApiError(meta.errors.forbidden); } else if (me.id !== user.id) { - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: user.id, - followerId: me.id, - }, - }); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(me.id).then(f => f.has(user.id)); if (!isFollowing) { throw new ApiError(meta.errors.forbidden); } diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index c292c6d6a3..80f0b0c484 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -13,6 +13,7 @@ import { FollowingEntityService } from '@/core/entities/FollowingEntityService.j import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -98,6 +99,7 @@ export default class extends Endpoint { // eslint- private followingEntityService: FollowingEntityService, private queryService: QueryService, private roleService: RoleService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy(ps.userId != null @@ -119,12 +121,7 @@ export default class extends Endpoint { // eslint- if (me == null) { throw new ApiError(meta.errors.forbidden); } else if (me.id !== user.id) { - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: user.id, - followerId: me.id, - }, - }); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(me.id).then(f => f.has(user.id)); if (!isFollowing) { throw new ApiError(meta.errors.forbidden); } diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 6416e43ff1..4602709067 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -134,7 +134,7 @@ export default class extends Endpoint { // eslint- 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 isFollowing = me && (await this.cacheService.userFollowingsCache.fetch(me.id)).has(ps.userId); const timeline = await this.fanoutTimelineEndpointService.timeline({ untilId, @@ -145,6 +145,7 @@ export default class extends Endpoint { // eslint- redisTimelines, useDbFallback: true, ignoreAuthorFromMute: true, + ignoreAuthorFromInstanceBlock: true, excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludePureRenotes: !ps.withRenotes, @@ -204,7 +205,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.channel', 'channel') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); if (ps.withChannelNotes) { if (!isSelf) query.andWhere(new Brackets(qb => { @@ -216,9 +218,10 @@ export default class extends Endpoint { // eslint- } this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query, true); if (me) { - this.queryService.generateMutedUserQuery(query, me, { id: ps.userId }); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId }); + this.queryService.generateBlockedUserQueryForNotes(query, me); } if (ps.withFiles) { @@ -228,26 +231,9 @@ export default class extends Endpoint { // eslint- if (!ps.withRenotes && !ps.withQuotes) { query.andWhere('note.renoteId IS NULL'); } else if (!ps.withRenotes) { - 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)'); - })); + this.queryService.andIsNotRenote(query, 'note'); } else if (!ps.withQuotes) { - query.andWhere(` - ( - note."renoteId" IS NULL - OR ( - note.text IS NULL - AND note.cw IS NULL - AND note."replyId" IS NULL - AND note."hasPoll" IS FALSE - AND note."fileIds" = '{}' - ) - ) - `); + this.queryService.andIsNotQuote(query, 'note'); } if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) { @@ -266,6 +252,6 @@ export default class extends Endpoint { // eslint- query.andWhere('"user"."isBot" = false'); } - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index 49c1190197..553787ad58 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -105,9 +105,15 @@ export default class extends Endpoint { // eslint- 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'); + .innerJoinAndSelect('reaction.note', 'note'); this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } const reactions = (await query .limit(ps.limit) diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 46af1f38ac..52dd2197b2 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -69,8 +69,9 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForUsers(query, me); this.queryService.generateBlockQueryForUsers(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + // TODO optimization: replace with exists() const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') .where('following.followerId = :followerId', { followerId: me.id }); 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 fda56ea6fe..f1a0fc5ddb 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 @@ -52,7 +52,7 @@ export default class extends Endpoint { // eslint- private userSearchService: UserSearchService, ) { super(meta, paramDef, (ps, me) => { - return this.userSearchService.search({ + return this.userSearchService.searchByUsernameAndHost({ username: ps.username, host: ps.host, }, { diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index 2d17c91e1d..138cef2ec5 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -3,14 +3,11 @@ * 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 { UserSearchService } from '@/core/UserSearchService.js'; export const meta = { tags: ['users'], @@ -51,79 +48,15 @@ export const paramDef = { @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 userEntityService: UserEntityService, + private userSearchService: UserSearchService, ) { super(meta, paramDef, async (ps, me) => { - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - - ps.query = ps.query.trim(); - const isUsername = ps.query.startsWith('@') && !ps.query.includes(' ') && ps.query.indexOf('@', 1) === -1; - - let users: MiUser[] = []; - - const nameQuery = this.usersRepository.createQueryBuilder('user') - .where(new Brackets(qb => { - qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - - if (isUsername) { - qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }); - } else if (this.userEntityService.validateLocalUsername(ps.query)) { // Also search username if it qualifies as username - 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'); - - if (ps.origin === 'local') { - nameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - nameQuery.andWhere('user.host IS NOT NULL'); - } - - 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(), - ); - } + const users = await this.userSearchService.search(ps.query.trim(), me?.id ?? null, { + offset: ps.offset, + limit: ps.limit, + origin: ps.origin, + }); return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }); }); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 118362149d..84eb661742 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -13,6 +13,7 @@ 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 { renderInlineError } from '@/misc/render-inline-error.js'; import { ApiError } from '../../error.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import type { FindOptionsWhere } from 'typeorm'; @@ -59,7 +60,8 @@ export const meta = { // up to 50 calls @ 4 per second limit: { - max: 50, + type: 'bucket', + size: 50, dripRate: 250, }, } as const; @@ -130,7 +132,7 @@ export default class extends Endpoint { // eslint- // 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}`); + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${renderInlineError(err)}`); throw new ApiError(meta.errors.failedToResolveRemoteUser); }); } else { diff --git a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts index 9426318e34..7139715293 100644 --- a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'read:admin:emoji', res: { diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 69799bdade..072dacf708 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -3,926 +3,249 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import querystring from 'querystring'; -import { megalodon, Entity, MegalodonInterface } from 'megalodon'; -import { IsNull } from 'typeorm'; -import multer from 'fastify-multer'; -import { Inject, Injectable } from '@nestjs/common'; -import type { AccessTokensRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; -import type { Config } from '@/config.js'; -import { DriveService } from '@/core/DriveService.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { ApiAccountMastodonRoute } from '@/server/api/mastodon/endpoints/account.js'; -import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.js'; -import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js'; -import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js'; -import { AuthenticateService } from '@/server/api/AuthenticateService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { AuthMastodonRoute } from './endpoints/auth.js'; -import { toBoolean } from './timelineArgs.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, FastifyRequest } from 'fastify'; - -export function getAccessToken(authorization: string | undefined): string | null { - const accessTokenArr = authorization?.split(' ') ?? [null]; - return accessTokenArr[accessTokenArr.length - 1]; -} - -export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessToken = getAccessToken(authorization); - return megalodon('misskey', BASE_URL, accessToken); -} +import { getErrorData, getErrorException, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; +import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; +import { ApiFilterMastodon } from '@/server/api/mastodon/endpoints/filter.js'; +import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js'; +import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js'; +import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js'; +import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js'; +import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; +import { ApiError } from '@/server/api/error.js'; +import { ServerUtilityService } from '@/server/ServerUtilityService.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js'; +import { convertAnnouncement, convertAttachment, MastodonConverters, convertRelationship } from './MastodonConverters.js'; +import type { Entity } from 'megalodon'; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() export class MastodonApiServerService { constructor( - @Inject(DI.meta) - private readonly serverSettings: MiMeta, - @Inject(DI.usersRepository) - private readonly usersRepository: UsersRepository, - @Inject(DI.userProfilesRepository) - private readonly userProfilesRepository: UserProfilesRepository, - @Inject(DI.accessTokensRepository) - private readonly accessTokensRepository: AccessTokensRepository, - @Inject(DI.config) - private readonly config: Config, - private readonly driveService: DriveService, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly logger: MastodonLogger, - private readonly authenticateService: AuthenticateService, - ) { } - - @bindThis - public async getAuthClient(request: FastifyRequest): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> { - const accessToken = getAccessToken(request.headers.authorization); - const [me] = await this.authenticateService.authenticate(accessToken); - - const baseUrl = `${request.protocol}://${request.host}`; - const client = megalodon('misskey', baseUrl, accessToken); - - return { client, me }; - } - - @bindThis - public async getAuthOnly(request: FastifyRequest): Promise { - const accessToken = getAccessToken(request.headers.authorization); - const [me] = await this.authenticateService.authenticate(accessToken); - return me; - } + private readonly clientService: MastodonClientService, + private readonly apiAccountMastodon: ApiAccountMastodon, + private readonly apiAppsMastodon: ApiAppsMastodon, + private readonly apiFilterMastodon: ApiFilterMastodon, + private readonly apiInstanceMastodon: ApiInstanceMastodon, + private readonly apiNotificationsMastodon: ApiNotificationsMastodon, + private readonly apiSearchMastodon: ApiSearchMastodon, + private readonly apiStatusMastodon: ApiStatusMastodon, + private readonly apiTimelineMastodon: ApiTimelineMastodon, + private readonly serverUtilityService: ServerUtilityService, + ) {} @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, - }, + this.serverUtilityService.addMultipartFormDataContentType(fastify); + this.serverUtilityService.addFormUrlEncodedContentType(fastify); + this.serverUtilityService.addCORS(fastify); + this.serverUtilityService.addFlattenedQueryType(fastify); + + // Convert JS exceptions into error responses + fastify.setErrorHandler((error, request, reply) => { + const data = getErrorData(error); + const status = getErrorStatus(error); + const exception = getErrorException(error); + + if (exception) { + this.logger.exception(request, exception); + } + + return reply.code(status).send(data); }); - fastify.addHook('onRequest', (_, reply, done) => { - reply.header('Access-Control-Allow-Origin', '*'); + // Log error responses (including converted JSON exceptions) + fastify.addHook('onSend', (request, reply, payload, done) => { + if (reply.statusCode >= 400) { + if (typeof(payload) === 'string' && String(reply.getHeader('content-type')).toLowerCase().includes('application/json')) { + const body = JSON.parse(payload); + const data = getErrorData(body); + this.logger.error(request, data, reply.statusCode); + } + } done(); }); - fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; - }); - payload.on('end', () => { - try { - const parsed = querystring.parse(body); - done(null, parsed); - } catch (e) { - done(e as Error); - } - }); - payload.on('error', done); + // Tell crawlers not to index API endpoints. + // https://developers.google.com/search/docs/crawling-indexing/block-indexing + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('X-Robots-Tag', 'noindex'); + done(); }); - fastify.register(multer.contentParser); + // External endpoints + this.apiAccountMastodon.register(fastify); + this.apiAppsMastodon.register(fastify); + this.apiFilterMastodon.register(fastify); + this.apiInstanceMastodon.register(fastify); + this.apiNotificationsMastodon.register(fastify); + this.apiSearchMastodon.register(fastify); + this.apiStatusMastodon.register(fastify); + this.apiTimelineMastodon.register(fastify); fastify.get('/v1/custom_emojis', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getInstanceCustomEmojis(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/custom_emojis', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/instance', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); - reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/instance', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceCustomEmojis(); + return reply.send(data.data); }); fastify.get('/v1/announcements', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('GET /v1/announcements', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceAnnouncements(); + const response = data.data.map((announcement) => convertAnnouncement(announcement)); + + return reply.send(response); }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); - const data = await client.dismissInstanceAnnouncement(_request.body['id']); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/announcements/${_request.body.id}/dismiss`, data); - reply.code(401).send(data); - } + if (!_request.body.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.dismissInstanceAnnouncement(_request.body.id); + + return reply.send(data.data); }); - fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('POST /v1/media', data); - reply.code(401).send(data); + fastify.post('/v1/media', async (_request, reply) => { + const multipartData = _request.savedRequestFiles?.[0]; + if (!multipartData) { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' }); } + + const client = this.clientService.getClient(_request); + const data = await client.uploadMedia(multipartData); + const response = convertAttachment(data.data as Entity.Attachment); + + return reply.send(response); }); - fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('POST /v2/media', data); - reply.code(401).send(data); + fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', async (_request, reply) => { + const multipartData = _request.savedRequestFiles?.[0]; + if (!multipartData) { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' }); } - }); - fastify.get('/v1/filters', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('GET /v1/filters', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.uploadMedia(multipartData, _request.body); + const response = convertAttachment(data.data as Entity.Attachment); + + return reply.send(response); }); fastify.get('/v1/trends', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceTrends(); + return reply.send(data.data); }); fastify.get('/v1/trends/tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends/tags', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceTrends(); + return reply.send(data.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.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('GET /v1/apps', data); - reply.code(401).send(data); - } + return reply.send([]); }); fastify.get('/v1/preferences', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('GET /v1/preferences', data); - reply.code(401).send(data); - } - }); - - //#region Accounts - fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.verifyCredentials()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/verify_credentials', data); - reply.code(401).send(data); - } - }); - - fastify.patch<{ - Body: { - discoverable?: string, - bot?: string, - display_name?: string, - note?: string, - avatar?: string, - header?: string, - locked?: string, - source?: { - privacy?: string, - sensitive?: string, - language?: string, - }, - fields_attributes?: { - name: string, - value: string, - }[], - }, - }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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 options = { - ..._request.body, - discoverable: toBoolean(_request.body.discoverable), - bot: toBoolean(_request.body.bot), - locked: toBoolean(_request.body.locked), - source: _request.body.source ? { - ..._request.body.source, - sensitive: toBoolean(_request.body.source.sensitive), - } : undefined, - }; - const data = await client.updateCredentials(options); - reply.send(await this.mastoConverters.convertAccount(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('PATCH /v1/accounts/update_credentials', data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in - try { - if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); - const data = await client.search(_request.query.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.mastoConverters.convertAccount(data.data.accounts[0])); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/lookup', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; - if (typeof ids === 'string') { - ids = [ids]; - } - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getRelationships(ids)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/relationships', data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getAccount(_request.params.id); - const account = await this.mastoConverters.convertAccount(data.data); - reply.send(account); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getStatuses()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getFeaturedTags(); - reply.send(data.data.map((tag) => convertFeaturedTag(tag))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFollowers()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFollowing()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getAccountLists(_request.params.id); - reply.send(data.data.map((list) => convertList(list))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addBlock()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmBlock()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addMute()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmMute()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getPreferences(); + return reply.send(data.data); }); fastify.get('/v1/followed_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFollowedTags(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/followed_tags', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getFollowedTags(); + return reply.send(data.data); }); - fastify.get('/v1/bookmarks', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getBookmarks()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/bookmarks', data); - reply.code(401).send(data); - } + fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => { + const { client, me } = await this.clientService.getAuthClient(_request); + + const data = await client.getBookmarks(parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + + return reply.send(response); }); - fastify.get('/v1/favourites', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFavourites()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/favourites', data); - reply.code(401).send(data); + fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => { + const { client, me } = await this.clientService.getAuthClient(_request); + + if (!me) { + throw new ApiError({ + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401, + }); } + + const args = { + ...parseTimelineArgs(_request.query), + userId: me.id, + }; + const data = await client.getFavourites(args); + const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + + return reply.send(response); }); - fastify.get('/v1/mutes', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getMutes()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/mutes', data); - reply.code(401).send(data); - } + fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => { + const client = this.clientService.getClient(_request); + + const data = await client.getMutes(parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + + return reply.send(response); }); - fastify.get('/v1/blocks', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getBlocks()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/blocks', data); - reply.code(401).send(data); - } + fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => { + const client = this.clientService.getClient(_request); + + const data = await client.getBlocks(parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + + return reply.send(response); }); - fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; - const data = await client.getFollowRequests(limit); - reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account as Entity.Account)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/follow_requests', data); - reply.code(401).send(data); - } + fastify.get<{ Querystring: { limit?: string } }>('/v1/follow_requests', async (_request, reply) => { + const client = this.clientService.getClient(_request); + + const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; + const data = await client.getFollowRequests(limit); + const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account as Entity.Account))); + + return reply.send(response); }); - fastify.post('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.acceptFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.acceptFollowRequest(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); }); - fastify.post('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rejectFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.rejectFollowRequest(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); }); //#endregion - //#region Search - fastify.get('/v1/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.SearchV1()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/search', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v2/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.SearchV2()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v2/search', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.getStatusTrends()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends/statuses', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v2/suggestions', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.getSuggestions()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v2/suggestions', data); - reply.code(401).send(data); - } - }); - //#endregion - - //#region Notifications - fastify.get('/v1/notifications', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.getNotifications()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/notifications', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/notification/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.getNotification()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/notification/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.rmNotification()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.rmNotifications()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/notifications/clear', data); - reply.code(401).send(data); - } - }); - //#endregion - - //#region Filters - fastify.get('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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.getFilter()) - : reply.send(await filter.getFilters()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('POST /v1/filters', data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.updateFilter()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.delete('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.rmFilter()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - //#endregion - - //#region Timelines - const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverters, this.logger, this); - - // 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.mastoConverters, this.logger, this.authenticateService, this); - - // 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, @@ -933,29 +256,20 @@ export class MastodonApiServerService { focus?: string, is_sensitive?: string, }, - }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const options = { - ..._request.body, - is_sensitive: toBoolean(_request.body.is_sensitive), - }; - const data = await client.updateMedia(_request.params.id, options); - reply.send(convertAttachment(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`PUT /v1/media/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - NoteEndpoint.updateStatus(); + }>('/v1/media/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const options = { + ..._request.body, + is_sensitive: toBoolean(_request.body.is_sensitive), + }; + const client = this.clientService.getClient(_request); + const data = await client.updateMedia(_request.params.id, options); + const response = convertAttachment(data.data); + + return reply.send(response); + }); - // DELETE Endpoint - NoteEndpoint.deleteStatus(); - //#endregion done(); } } diff --git a/packages/backend/src/server/api/mastodon/MastodonClientService.ts b/packages/backend/src/server/api/mastodon/MastodonClientService.ts new file mode 100644 index 0000000000..d7b74bb751 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/MastodonClientService.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Misskey } from 'megalodon'; +import { Injectable } from '@nestjs/common'; +import { MiLocalUser } from '@/models/User.js'; +import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import type { FastifyRequest } from 'fastify'; + +@Injectable() +export class MastodonClientService { + constructor( + private readonly authenticateService: AuthenticateService, + ) {} + + /** + * Gets the authenticated user and API client for a request. + */ + public async getAuthClient(request: FastifyRequest, accessToken?: string | null): Promise<{ client: Misskey, me: MiLocalUser | null }> { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + + const me = await this.getAuth(request, accessToken); + const client = this.getClient(request, accessToken); + + return { client, me }; + } + + /** + * Gets the authenticated client user for a request. + */ + public async getAuth(request: FastifyRequest, accessToken?: string | null): Promise { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + const [me] = await this.authenticateService.authenticate(accessToken); + return me; + } + + /** + * Creates an authenticated API client for a request. + */ + public getClient(request: FastifyRequest, accessToken?: string | null): Misskey { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + + // TODO pass agent? + const baseUrl = this.getBaseUrl(request); + const userAgent = request.headers['user-agent']; + return new Misskey(baseUrl, accessToken, userAgent); + } + + readonly getBaseUrl = getBaseUrl; +} + +/** + * Gets the base URL (origin) of the incoming request + */ +export function getBaseUrl(request: FastifyRequest): string { + return `${request.protocol}://${request.host}`; +} + +/** + * Extracts the first access token from an authorization header + * Returns null if none were found. + */ +function getAccessToken(authorization: string | undefined): string | null { + const accessTokenArr = authorization?.split(' ') ?? [null]; + return accessTokenArr[accessTokenArr.length - 1]; +} diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts similarity index 68% rename from packages/backend/src/server/api/mastodon/converters.ts rename to packages/backend/src/server/api/mastodon/MastodonConverters.ts index b6ff5bc59a..df8d68042a 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -4,8 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Entity } from 'megalodon'; -import mfm from '@transfem-org/sfm-js'; +import { Entity, MastodonEntity, MisskeyEntity } from 'megalodon'; +import mfm from 'mfm-js'; +import { MastodonNotificationType } from 'megalodon/lib/src/mastodon/notification.js'; +import { NotificationType } from 'megalodon/lib/src/notification.js'; import { DI } from '@/di-symbols.js'; import { MfmService } from '@/core/MfmService.js'; import type { Config } from '@/config.js'; @@ -19,6 +21,8 @@ import { IdService } from '@/core/IdService.js'; import type { Packed } from '@/misc/json-schema.js'; import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { appendContentWarning } from '@/misc/append-content-warning.js'; +import { isRenote } from '@/misc/is-renote.js'; // Missing from Megalodon apparently // https://docs.joinmastodon.org/entities/StatusEdit/ @@ -47,7 +51,7 @@ export const escapeMFM = (text: string): string => text .replace(/\r?\n/g, '
'); @Injectable() -export class MastoConverters { +export class MastodonConverters { constructor( @Inject(DI.config) private readonly config: Config, @@ -68,7 +72,6 @@ export class MastoConverters { private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention { let acct = u.username; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let acctUrl = `https://${u.host || this.config.host}/@${u.username}`; let url: string | null = null; if (u.host) { @@ -136,10 +139,10 @@ export class MastoConverters { }); } - private async encodeField(f: Entity.Field): Promise { + private encodeField(f: Entity.Field): MastodonEntity.Field { return { name: f.name, - value: await this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), + value: this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), verified_at: null, }; } @@ -161,13 +164,15 @@ export class MastoConverters { }); const fqn = `${user.username}@${user.host ?? this.config.hostname}`; let acct = user.username; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing 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}`; } + + const bioText = profile?.description && this.mfmService.toMastoApiHtml(mfm.parse(profile.description)); + return awaitAll({ id: account.id, username: user.username, @@ -179,16 +184,16 @@ export class MastoConverters { followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0, following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0, statuses_count: user.notesCount, - note: profile?.description ?? '', + note: bioText ?? '', 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', + avatar: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', + avatar_static: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', + header: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png', + header_static: 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)) ?? []), + fields: profile?.fields.map(p => this.encodeField(p)) ?? [], bot: user.isBot, discoverable: user.isExplorable, noindex: user.noindex, @@ -198,44 +203,59 @@ export class MastoConverters { }); } - public async getEdits(id: string, me?: MiLocalUser | null) { + public async getEdits(id: string, me: MiLocalUser | null): Promise { const note = await this.mastodonDataService.getNote(id, me); if (!note) { return []; } - const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p)); + + const noteUser = await this.getUser(note.userId); + const account = await this.convertAccount(noteUser); const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } }); - const history: Promise[] = []; + const history: StatusEdit[] = []; + + const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers); + const renote = isRenote(note) ? await this.mastodonDataService.requireNote(note.renoteId, me) : null; // TODO this looks wrong, according to mastodon docs let lastDate = this.idService.parse(note.id).date; + for (const edit of edits) { - const files = this.driveFileEntityService.packManyByIds(edit.fileIds); + // TODO avoid re-packing files for each edit + const files = await this.driveFileEntityService.packManyByIds(edit.fileIds); + + const cw = appendContentWarning(edit.cw, noteUser.mandatoryCW) ?? ''; + + const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId); + const quoteUri = isQuote + ? renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}` + : null; + const item = { - account: noteUser, - content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''), + account: account, + content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), mentionedRemoteUsers, false, quoteUri) ?? '', created_at: lastDate.toISOString(), - emojis: [], - sensitive: edit.cw != null && edit.cw.length > 0, - spoiler_text: edit.cw ?? '', - media_attachments: files.then(files => files.length > 0 ? files.map((f) => this.encodeFile(f)) : []), + emojis: [], //FIXME + sensitive: !!cw, + spoiler_text: cw, + media_attachments: files.length > 0 ? files.map((f) => this.encodeFile(f)) : [], }; lastDate = edit.updatedAt; - history.push(awaitAll(item)); + history.push(item); } - return await Promise.all(history); + return history; } - private async convertReblog(status: Entity.Status | null, me?: MiLocalUser | null): Promise { + private async convertReblog(status: Entity.Status | null, me: MiLocalUser | null): Promise { if (!status) return null; return await this.convertStatus(status, me); } - public async convertStatus(status: Entity.Status, me?: MiLocalUser | null): Promise { + public async convertStatus(status: Entity.Status, me: MiLocalUser | null, hints?: { note?: MiNote, user?: MiUser }): Promise { const convertedAccount = this.convertAccount(status.account); - const note = await this.mastodonDataService.requireNote(status.id, me); - const noteUser = await this.getUser(status.account.id); + const note = hints?.note ?? await this.mastodonDataService.requireNote(status.id, me); + const noteUser = hints?.user ?? note.user ?? await this.getUser(status.account.id); const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers); const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host); @@ -255,7 +275,7 @@ export class MastoConverters { this.getUser(p) .then(u => this.encode(u, mentionedRemoteUsers)) .catch(() => null))) - .then(p => p.filter(m => m)) as Promise; + .then((p: Entity.Mention[]) => p.filter(m => m)); const tags = note.tags.map(tag => { return { @@ -265,7 +285,6 @@ export class MastoConverters { }); // This must mirror the usual isQuote / isPureRenote logic used elsewhere. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId); const renote: Promise | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null; @@ -277,11 +296,11 @@ export class MastoConverters { const text = note.text; const content = text !== null - ? quoteUri - .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri)) - .then(p => p ?? escapeMFM(text)) + ? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text)) : ''; + const cw = appendContentWarning(note.cw, noteUser.mandatoryCW) ?? ''; + const reblogged = await this.mastodonDataService.hasReblog(note.id, me); // noinspection ES6MissingAwait @@ -292,11 +311,12 @@ export class MastoConverters { account: convertedAccount, in_reply_to_id: note.replyId, in_reply_to_account_id: note.replyUserId, - reblog: !isQuote ? await this.convertReblog(status.reblog, me) : null, + reblog: !isQuote ? this.convertReblog(status.reblog, me) : null, content: content, content_type: 'text/x.misskeymarkdown', text: note.text, created_at: status.created_at, + edited_at: note.updatedAt?.toISOString() ?? null, emojis: emoji, replies_count: note.repliesCount, reblogs_count: note.renoteCount, @@ -304,10 +324,10 @@ export class MastoConverters { reblogged, favourited: status.favourited, muted: status.muted, - sensitive: status.sensitive, - spoiler_text: note.cw ?? '', + sensitive: status.sensitive || !!cw, + spoiler_text: cw, visibility: status.visibility, - media_attachments: status.media_attachments.map(a => convertAttachment(a)), + media_attachments: status.media_attachments.map((a: Entity.Account) => convertAttachment(a)), mentions: mentions, tags: tags, card: null, //FIXME @@ -315,30 +335,47 @@ export class MastoConverters { application: null, //FIXME language: null, //FIXME pinned: false, //FIXME - reactions: status.emoji_reactions, - emoji_reactions: status.emoji_reactions, bookmarked: false, //FIXME - quote: isQuote ? await this.convertReblog(status.reblog, me) : null, - edited_at: note.updatedAt?.toISOString() ?? null, + quote_id: isQuote ? status.reblog?.id : undefined, + quote: isQuote ? this.convertReblog(status.reblog, me) : null, + reactions: status.emoji_reactions, }); } - public async convertConversation(conversation: Entity.Conversation, me?: MiLocalUser | null): Promise { + public async convertConversation(conversation: Entity.Conversation, me: MiLocalUser | null): Promise { return { id: conversation.id, - accounts: await Promise.all(conversation.accounts.map(a => this.convertAccount(a))), + accounts: await Promise.all(conversation.accounts.map((a: Entity.Account) => this.convertAccount(a))), last_status: conversation.last_status ? await this.convertStatus(conversation.last_status, me) : null, unread: conversation.unread, }; } - public async convertNotification(notification: Entity.Notification, me?: MiLocalUser | null): Promise { + public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { + const status = notification.status + ? await this.convertStatus(notification.status, me).catch(() => null) + : null; + + // We sometimes get notifications for inaccessible notes, these should be ignored. + if (!status) { + return null; + } + return { account: await this.convertAccount(notification.account), created_at: notification.created_at, id: notification.id, - status: notification.status ? await this.convertStatus(notification.status, me) : undefined, - type: notification.type, + status, + type: convertNotificationType(notification.type as NotificationType), + }; + } + + public convertApplication(app: MisskeyEntity.App): MastodonEntity.Application { + return { + name: app.name, + scopes: app.permission, + redirect_uri: app.callbackUrl, + redirect_uris: [app.callbackUrl], }; } } @@ -348,12 +385,26 @@ function simpleConvert(data: T): T { return Object.assign({}, data); } -export function convertAccount(account: Entity.Account) { - return simpleConvert(account); +function convertNotificationType(type: NotificationType): MastodonNotificationType { + switch (type) { + case 'emoji_reaction': return 'reaction'; + case 'poll_vote': + case 'poll_expired': + return 'poll'; + // Not supported by mastodon + case 'move': + return type as MastodonNotificationType; + default: return type; + } } -export function convertAnnouncement(announcement: Entity.Announcement) { - return simpleConvert(announcement); + +export function convertAnnouncement(announcement: Entity.Announcement): MastodonEntity.Announcement { + return { + ...announcement, + updated_at: announcement.updated_at ?? announcement.published_at, + }; } + export function convertAttachment(attachment: Entity.Attachment): MastodonEntity.Attachment { const { width, height } = attachment.meta?.original ?? attachment.meta ?? {}; const size = (width && height) ? `${width}x${height}` : undefined; @@ -379,28 +430,24 @@ export function convertAttachment(attachment: Entity.Attachment): MastodonEntity } : null, }; } -export function convertFilter(filter: Entity.Filter) { +export function convertFilter(filter: Entity.Filter): MastodonEntity.Filter { return simpleConvert(filter); } -export function convertList(list: Entity.List) { - return simpleConvert(list); +export function convertList(list: Entity.List): MastodonEntity.List { + return { + id: list.id, + title: list.title, + replies_policy: list.replies_policy ?? 'followed', + }; } -export function convertFeaturedTag(tag: Entity.FeaturedTag) { +export function convertFeaturedTag(tag: Entity.FeaturedTag): MastodonEntity.FeaturedTag { return simpleConvert(tag); } -export function convertPoll(poll: Entity.Poll) { +export function convertPoll(poll: Entity.Poll): MastodonEntity.Poll { return simpleConvert(poll); } -// noinspection JSUnusedGlobalSymbols -export function convertReaction(reaction: Entity.Reaction) { - if (reaction.accounts) { - reaction.accounts = reaction.accounts.map(convertAccount); - } - return reaction; -} - // Megalodon sometimes returns broken / stubbed relationship data export function convertRelationship(relationship: Partial & { id: string }): MastodonEntity.Relationship { return { @@ -421,8 +468,3 @@ export function convertRelationship(relationship: Partial & note: relationship.note ?? '', }; } - -// noinspection JSUnusedGlobalSymbols -export function convertStatusSource(status: Entity.StatusSource) { - return simpleConvert(status); -} diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts index 671ecdcbed..e080cb10bd 100644 --- a/packages/backend/src/server/api/mastodon/MastodonDataService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts @@ -7,8 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import { QueryService } from '@/core/QueryService.js'; -import type { MiNote, NotesRepository } from '@/models/_.js'; -import type { MiLocalUser } from '@/models/User.js'; +import type { MiChannel, MiNote, NotesRepository } from '@/models/_.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; import { ApiError } from '../error.js'; /** @@ -27,8 +27,8 @@ export class MastodonDataService { /** * Fetches a note in the context of the current user, and throws an exception if not found. */ - public async requireNote(noteId: string, me?: MiLocalUser | null): Promise { - const note = await this.getNote(noteId, me); + public async requireNote(noteId: string, me: MiLocalUser | null | undefined, relations?: Rel): Promise> { + const note = await this.getNote(noteId, me, relations); if (!note) { throw new ApiError({ @@ -46,20 +46,47 @@ export class MastodonDataService { /** * Fetches a note in the context of the current user. */ - public async getNote(noteId: string, me?: MiLocalUser | null): Promise { + public async getNote(noteId: string, me: MiLocalUser | null | undefined, relations?: Rel): Promise | null> { // Root query: note + required dependencies const query = this.notesRepository .createQueryBuilder('note') - .where('note.id = :noteId', { noteId }) - .innerJoinAndSelect('note.user', 'user'); + .where('note.id = :noteId', { noteId }); + + // Load relations + if (relations) { + if (relations.reply) { + query.leftJoinAndSelect('note.reply', 'reply'); + if (typeof(relations.reply) === 'object') { + if (relations.reply.reply) query.leftJoinAndSelect('reply.reply', 'replyReply'); + if (relations.reply.renote) query.leftJoinAndSelect('reply.renote', 'replyRenote'); + if (relations.reply.user) query.innerJoinAndSelect('reply.user', 'replyUser'); + if (relations.reply.channel) query.leftJoinAndSelect('reply.channel', 'replyChannel'); + } + } + if (relations.renote) { + query.leftJoinAndSelect('note.renote', 'renote'); + if (typeof(relations.renote) === 'object') { + if (relations.renote.reply) query.leftJoinAndSelect('renote.reply', 'renoteReply'); + if (relations.renote.renote) query.leftJoinAndSelect('renote.renote', 'renoteRenote'); + if (relations.renote.user) query.innerJoinAndSelect('renote.user', 'renoteUser'); + if (relations.renote.channel) query.leftJoinAndSelect('renote.channel', 'renoteChannel'); + } + } + if (relations.user) { + query.innerJoinAndSelect('note.user', 'user'); + } + if (relations.channel) { + query.leftJoinAndSelect('note.channel', 'channel'); + } + } // Restrict visibility this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } - return await query.getOne(); + return await query.getOne() as NoteWithRelations | null; } /** @@ -82,3 +109,41 @@ export class MastodonDataService { }); } } + +interface NoteRelations { + reply?: boolean | { + reply?: boolean; + renote?: boolean; + user?: boolean; + channel?: boolean; + }; + renote?: boolean | { + reply?: boolean; + renote?: boolean; + user?: boolean; + channel?: boolean; + }; + user?: boolean; + channel?: boolean; +} + +type NoteWithRelations = MiNote & { + reply: Rel extends { reply: false } + ? null + : null | (MiNote & { + reply: Rel['reply'] extends { reply: true } ? MiNote | null : null; + renote: Rel['reply'] extends { renote: true } ? MiNote | null : null; + user: Rel['reply'] extends { user: true } ? MiUser : null; + channel: Rel['reply'] extends { channel: true } ? MiChannel | null : null; + }); + renote: Rel extends { renote: false } + ? null + : null | (MiNote & { + reply: Rel['renote'] extends { reply: true } ? MiNote | null : null; + renote: Rel['renote'] extends { renote: true } ? MiNote | null : null; + user: Rel['renote'] extends { user: true } ? MiUser : null; + channel: Rel['renote'] extends { channel: true } ? MiChannel | null : null; + }); + user: Rel extends { user: true } ? MiUser : null; + channel: Rel extends { channel: true } ? MiChannel | null : null; +}; diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index bb844773c4..5ea69ed151 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -4,36 +4,212 @@ */ import { Injectable } from '@nestjs/common'; -import Logger, { Data } from '@/logger.js'; +import { isAxiosError } from 'axios'; +import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; +import { ApiError } from '@/server/api/error.js'; +import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js'; +import { AuthenticationError } from '@/server/api/AuthenticateService.js'; +import type { FastifyRequest } from 'fastify'; @Injectable() export class MastodonLogger { public readonly logger: Logger; - constructor(loggerService: LoggerService) { + constructor( + loggerService: LoggerService, + ) { this.logger = loggerService.getLogger('masto-api'); } - public error(endpoint: string, error: Data): void { - this.logger.error(`Error in mastodon API endpoint ${endpoint}:`, error); + public error(request: FastifyRequest, error: MastodonError, status: number): void { + const path = getPath(request); + + if (status >= 400 && status <= 499) { // Client errors + this.logger.debug(`Error in mastodon endpoint ${request.method} ${path}:`, error); + } else { // Server errors + this.logger.error(`Error in mastodon endpoint ${request.method} ${path}:`, error); + } + } + + public exception(request: FastifyRequest, ex: Error): void { + const path = getPath(request); + + // Exceptions are always server errors, and should therefore always be logged. + this.logger.error(`Exception in mastodon endpoint ${request.method} ${path}:`, ex); } } -export function getErrorData(error: unknown): Data { - if (error == null) return {}; - if (typeof(error) === 'string') return error; - if (typeof(error) === 'object') { - if ('response' in error) { - if (typeof(error.response) === 'object' && error.response) { - if ('data' in error.response) { - if (typeof(error.response.data) === 'object' && error.response.data) { - return error.response.data as Record; - } +function getPath(request: FastifyRequest): string { + try { + return new URL(request.url, getBaseUrl(request)).pathname; + } catch { + return request.url; + } +} + +// TODO move elsewhere +export interface MastodonError { + error: string; + error_description?: string; +} + +export function getErrorException(error: unknown): Error | null { + if (!(error instanceof Error)) { + return null; + } + + // AxiosErrors need special decoding + if (isAxiosError(error)) { + // Axios errors with a response are from the remote + if (error.response) { + return null; + } + + // This is the inner exception, basically + if (error.cause && !isAxiosError(error.cause)) { + if (!error.cause.stack) { + error.cause.stack = error.stack; + } + + return error.cause; + } + + const ex = new Error(); + ex.name = error.name; + ex.stack = error.stack; + ex.message = error.message; + ex.cause = error.cause; + return ex; + } + + // AuthenticationError is a client error + if (error instanceof AuthenticationError) { + return null; + } + + return error; +} + +export function getErrorData(error: unknown): MastodonError { + // Axios wraps errors from the backend + error = unpackAxiosError(error); + + if (!error || typeof(error) !== 'object') { + return { + error: 'UNKNOWN_ERROR', + error_description: String(error), + }; + } + + if (error instanceof ApiError) { + return convertApiError(error); + } + + if ('code' in error && typeof (error.code) === 'string') { + if ('message' in error && typeof (error.message) === 'string') { + return convertApiError(error as ApiError); + } + } + + if ('error' in error && typeof (error.error) === 'string') { + if ('message' in error && typeof (error.message) === 'string') { + return convertErrorMessageError(error as { error: string, message: string }); + } + } + + if (error instanceof Error) { + return convertGenericError(error); + } + + if ('error' in error && typeof(error.error) === 'string') { + // "error_description" is string, undefined, or not present. + if (!('error_description' in error) || typeof(error.error_description) === 'string' || typeof(error.error_description) === 'undefined') { + return convertMastodonError(error as MastodonError); + } + } + + return { + error: 'INTERNAL_ERROR', + error_description: 'Internal error occurred. Please contact us if the error persists.', + }; +} + +function unpackAxiosError(error: unknown): unknown { + if (isAxiosError(error)) { + if (error.response) { + if (error.response.data && typeof(error.response.data) === 'object') { + if ('error' in error.response.data && error.response.data.error && typeof(error.response.data.error) === 'object') { + return error.response.data.error; } + + return error.response.data; + } + + // No data - this is a fallback to avoid leaking request/response details in the error + return undefined; + } + + if (error.cause && !isAxiosError(error.cause)) { + if (!error.cause.stack) { + error.cause.stack = error.stack; + } + + return error.cause; + } + + // No data - this is a fallback to avoid leaking request/response details in the error + return String(error); + } + + return error; +} + +function convertApiError(apiError: ApiError): MastodonError { + return { + error: apiError.code, + error_description: apiError.message, + }; +} + +function convertErrorMessageError(error: { error: string, message: string }): MastodonError { + return { + error: error.error, + error_description: error.message, + }; +} + +function convertGenericError(error: Error): MastodonError { + return { + error: 'INTERNAL_ERROR', + error_description: String(error), + }; +} + +function convertMastodonError(error: MastodonError): MastodonError { + return { + error: error.error, + error_description: error.error_description, + }; +} + +export function getErrorStatus(error: unknown): number { + if (error && typeof(error) === 'object') { + // Axios wraps errors from the backend + if ('response' in error && typeof (error.response) === 'object' && error.response) { + if ('status' in error.response && typeof(error.response.status) === 'number') { + return error.response.status; } } - return error as Record; + + if ('httpStatusCode' in error && typeof(error.httpStatusCode) === 'number') { + return error.httpStatusCode; + } + + if ('statusCode' in error && typeof(error.statusCode) === 'number') { + return error.statusCode; + } } - return { error }; + + return 500; } diff --git a/packages/backend/src/server/api/mastodon/timelineArgs.ts b/packages/backend/src/server/api/mastodon/argsUtils.ts similarity index 94% rename from packages/backend/src/server/api/mastodon/timelineArgs.ts rename to packages/backend/src/server/api/mastodon/argsUtils.ts index 3fba8ec57a..167d493ab6 100644 --- a/packages/backend/src/server/api/mastodon/timelineArgs.ts +++ b/packages/backend/src/server/api/mastodon/argsUtils.ts @@ -22,12 +22,12 @@ export interface TimelineArgs { // Values taken from https://docs.joinmastodon.org/client/intro/#boolean export function toBoolean(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; + if (!value) return undefined; return !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value); } export function toInt(value: string | undefined): number | undefined { - if (value === undefined) return undefined; + if (!value) return undefined; return parseInt(value); } 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 085314059b..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -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 index 79cdddcb9e..6a1af62be7 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -3,14 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; -import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; -import { MiLocalUser } from '@/models/User.js'; -import { MastoConverters, convertRelationship } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { Inject, Injectable } from '@nestjs/common'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; +import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; +import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js'; +import type { FastifyInstance } from 'fastify'; -export interface ApiAccountMastodonRoute { +interface ApiAccountMastodonRoute { Params: { id?: string }, Querystring: TimelineArgs & { acct?: string }, Body: { notifications?: boolean } @@ -19,133 +22,270 @@ export interface ApiAccountMastodonRoute { @Injectable() export class ApiAccountMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, - private readonly mastoConverters: MastoConverters, + @Inject(DI.userProfilesRepository) + private readonly userProfilesRepository: UserProfilesRepository, + + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, + + private readonly clientService: MastodonClientService, + private readonly mastoConverters: MastodonConverters, + private readonly driveService: DriveService, ) {} - public async verifyCredentials() { - const data = await this.client.verifyAccountCredentials(); - const acct = await this.mastoConverters.convertAccount(data.data); - return Object.assign({}, acct, { - source: { - note: acct.note, - fields: acct.fields, - privacy: '', - sensitive: false, - language: '', + public register(fastify: FastifyInstance): void { + fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { + const client = this.clientService.getClient(_request); + const data = await client.verifyAccountCredentials(); + const acct = await this.mastoConverters.convertAccount(data.data); + const response = Object.assign({}, acct, { + source: { + note: acct.note, + fields: acct.fields, + privacy: 'public', + sensitive: false, + language: '', + }, + }); + return reply.send(response); + }); + + fastify.patch<{ + Body: { + discoverable?: string, + bot?: string, + display_name?: string, + note?: string, + avatar?: string, + header?: string, + locked?: string, + source?: { + privacy?: string, + sensitive?: string, + language?: string, + }, + fields_attributes?: { + name: string, + value: string, + }[], }, + }>('/v1/accounts/update_credentials', async (_request, reply) => { + const accessTokens = _request.headers.authorization; + const client = this.clientService.getClient(_request); + // Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. + if (_request.savedRequestFiles?.length && accessTokens) { + const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); + const avatar = _request.savedRequestFiles.find(obj => { + return obj.fieldname === 'avatar'; + }); + const header = _request.savedRequestFiles.find(obj => { + return obj.fieldname === 'header'; + }); + + if (tokeninfo && avatar) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: avatar.filepath, + name: avatar.filename && avatar.filename !== 'file' ? avatar.filename : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + _request.body.avatar = upload.id; + } + } else if (tokeninfo && header) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: header.filepath, + name: header.filename && header.filename !== 'file' ? header.filename : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + _request.body.header = upload.id; + } + } + } + + if (_request.body.fields_attributes) { + for (const field of _request.body.fields_attributes) { + 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'); + } + } + _request.body.fields_attributes = _request.body.fields_attributes.filter(field => field.name.trim().length > 0 && field.value.length > 0); + } + + const options = { + ..._request.body, + discoverable: toBoolean(_request.body.discoverable), + bot: toBoolean(_request.body.bot), + locked: toBoolean(_request.body.locked), + source: _request.body.source ? { + ..._request.body.source, + sensitive: toBoolean(_request.body.source.sensitive), + } : undefined, + }; + const data = await client.updateCredentials(options); + const response = await this.mastoConverters.convertAccount(data.data); + + return reply.send(response); + }); + + fastify.get<{ Querystring: { acct?: string } }>('/v1/accounts/lookup', async (_request, reply) => { + if (!_request.query.acct) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "acct"' }); + + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.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 })) ?? []; + const response = await this.mastoConverters.convertAccount(data.data.accounts[0]); + + return reply.send(response); + }); + + fastify.get('/v1/accounts/relationships', async (_request, reply) => { + if (!_request.query.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getRelationships(_request.query.id); + const response = data.data.map(relationship => convertRelationship(relationship)); + + return reply.send(response); + }); + + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccount(_request.params.id); + const account = await this.mastoConverters.convertAccount(data.data); + + return reply.send(account); + }); + + fastify.get('/v1/accounts/:id/statuses', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(request); + const args = parseTimelineArgs(request.query); + const data = await client.getAccountStatuses(request.params.id, args); + const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); + + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getFeaturedTags(); + const response = data.data.map((tag) => convertFeaturedTag(tag)); + + return reply.send(response); + }); + + fastify.get('/v1/accounts/:id/followers', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(request); + const data = await client.getAccountFollowers( + request.params.id, + parseTimelineArgs(request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); + + fastify.get('/v1/accounts/:id/following', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(request); + const data = await client.getAccountFollowing( + request.params.id, + parseTimelineArgs(request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); + + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccountLists(_request.params.id); + const response = data.data.map((list) => convertList(list)); + + return reply.send(response); + }); + + fastify.post('/v1/accounts/:id/follow', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.followAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = true; // TODO this is wrong, follow may not have processed immediately + + return reply.send(acct); + }); + + fastify.post('/v1/accounts/:id/unfollow', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unfollowAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = false; + + return reply.send(acct); + }); + + fastify.post('/v1/accounts/:id/block', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.blockAccount(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); + }); + + fastify.post('/v1/accounts/:id/unblock', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unblockAccount(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); + }); + + fastify.post('/v1/accounts/:id/mute', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.muteAccount( + _request.params.id, + _request.body.notifications ?? true, + ); + const response = convertRelationship(data.data); + + return reply.send(response); + }); + + fastify.post('/v1/accounts/:id/unmute', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unmuteAccount(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); }); } - - public async lookup() { - if (!this.request.query.acct) throw new Error('Missing required property "acct"'); - const data = await this.client.search(this.request.query.acct, { type: 'accounts' }); - return this.mastoConverters.convertAccount(data.data.accounts[0]); - } - - public async getRelationships(reqIds: string[]) { - const data = await this.client.getRelationships(reqIds); - return data.data.map(relationship => convertRelationship(relationship)); - } - - public async getStatuses() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountStatuses(this.request.params.id, parseTimelineArgs(this.request.query)); - return await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, this.me))); - } - - public async getFollowers() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountFollowers( - this.request.params.id, - parseTimelineArgs(this.request.query), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - } - - public async getFollowing() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountFollowing( - this.request.params.id, - parseTimelineArgs(this.request.query), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - } - - public async addFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.followAccount(this.request.params.id); - const acct = convertRelationship(data.data); - acct.following = true; - return acct; - } - - public async rmFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unfollowAccount(this.request.params.id); - const acct = convertRelationship(data.data); - acct.following = false; - return acct; - } - - public async addBlock() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.blockAccount(this.request.params.id); - return convertRelationship(data.data); - } - - public async rmBlock() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unblockAccount(this.request.params.id); - return convertRelationship(data.data); - } - - public async addMute() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.muteAccount( - this.request.params.id, - this.request.body.notifications ?? true, - ); - return convertRelationship(data.data); - } - - public async rmMute() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unmuteAccount(this.request.params.id); - return convertRelationship(data.data); - } - - public async getBookmarks() { - const data = await this.client.getBookmarks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); - } - - public async getFavourites() { - const data = await this.client.getFavourites(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); - } - - public async getMutes() { - const data = await this.client.getMutes(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - } - - public async getBlocks() { - const data = await this.client.getBlocks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - } - - public async acceptFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.acceptFollowRequest(this.request.params.id); - return convertRelationship(data.data); - } - - public async rejectFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.rejectFollowRequest(this.request.params.id); - return convertRelationship(data.data); - } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts new file mode 100644 index 0000000000..72b520c74a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; +import type { FastifyInstance } 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 interface AuthPayload { + scopes?: string | string[], + redirect_uris?: string | string[], + client_name?: string | string[], + website?: string | string[], +} + +// Not entirely right, but it gets TypeScript to work so *shrug* +type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; + +@Injectable() +export class ApiAppsMastodon { + constructor( + private readonly clientService: MastodonClientService, + private readonly mastoConverters: MastodonConverters, + ) {} + + public register(fastify: FastifyInstance): void { + fastify.post('/v1/apps', async (_request, reply) => { + const body = _request.body ?? _request.query; + if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' }); + if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' }); + if (Array.isArray(body.redirect_uris)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "redirect_uris": only one value is allowed' }); + if (!body.client_name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "client_name"' }); + if (Array.isArray(body.client_name)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "client_name": only one value is allowed' }); + if (Array.isArray(body.website)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "website": only one value is allowed' }); + + let scope = body.scopes; + if (typeof scope === 'string') { + scope = scope.split(/[ +]/g); + } + + 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 client = this.clientService.getClient(_request); + const appData = await client.registerApp(body.client_name, { + scopes: Array.from(pushScope), + redirect_uri: body.redirect_uris, + website: body.website, + }); + + const response = { + id: Math.floor(Math.random() * 100).toString(), + name: appData.name, + website: body.website, + redirect_uri: body.redirect_uris, + client_id: Buffer.from(appData.url || '').toString('base64'), + client_secret: appData.clientSecret, + }; + + return reply.send(response); + }); + + fastify.get('/v1/apps/verify_credentials', async (_request, reply) => { + const client = this.clientService.getClient(_request); + const data = await client.verifyAppCredentials(); + const response = this.mastoConverters.convertApplication(data.data); + return reply.send(response); + }); + } +} + 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 b58cc902da..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -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 interface AuthPayload { - scopes?: string | string[], - redirect_uris?: string, - client_name?: string, - website?: string, -} - -// Not entirely right, but it gets TypeScript to work so *shrug* -export type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; - -export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { - const body = request.body ?? request.query; - if (!body.scopes) throw new Error('Missing required payload "scopes"'); - if (!body.redirect_uris) throw new Error('Missing required payload "redirect_uris"'); - if (!body.client_name) throw new Error('Missing required payload "client_name"'); - - let scope = body.scopes; - if (typeof scope === 'string') { - scope = scope.split(/[ +]/g); - } - - 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 red = body.redirect_uris; - const appData = await client.registerApp(body.client_name, { - scopes: Array.from(pushScope), - redirect_uris: red, - website: body.website, - }); - - return { - id: Math.floor(Math.random() * 100).toString(), - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url || '').toString('base64'), // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - client_secret: appData.clientSecret, - }; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 382f0a8f1f..f2bd0052d5 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -3,12 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { toBoolean } from '@/server/api/mastodon/timelineArgs.js'; -import { convertFilter } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { Injectable } from '@nestjs/common'; +import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { convertFilter } from '../MastodonConverters.js'; +import type { FastifyInstance } from 'fastify'; -export interface ApiFilterMastodonRoute { +interface ApiFilterMastodonRoute { Params: { id?: string, }, @@ -21,55 +22,78 @@ export interface ApiFilterMastodonRoute { } } +@Injectable() export class ApiFilterMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, + private readonly clientService: MastodonClientService, ) {} - public async getFilters() { - const data = await this.client.getFilters(); - return data.data.map((filter) => convertFilter(filter)); - } + public register(fastify: FastifyInstance): void { + fastify.get('/v1/filters', async (_request, reply) => { + const client = this.clientService.getClient(_request); - public async getFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getFilter(this.request.params.id); - return convertFilter(data.data); - } + const data = await client.getFilters(); + const response = data.data.map((filter) => convertFilter(filter)); - public async createFilter() { - if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); - if (!this.request.body.context) throw new Error('Missing required payload "context"'); - const options = { - phrase: this.request.body.phrase, - context: this.request.body.context, - irreversible: toBoolean(this.request.body.irreversible), - whole_word: toBoolean(this.request.body.whole_word), - expires_in: this.request.body.expires_in, - }; - const data = await this.client.createFilter(this.request.body.phrase, this.request.body.context, options); - return convertFilter(data.data); - } + return reply.send(response); + }); - public async updateFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); - if (!this.request.body.context) throw new Error('Missing required payload "context"'); - const options = { - phrase: this.request.body.phrase, - context: this.request.body.context, - irreversible: toBoolean(this.request.body.irreversible), - whole_word: toBoolean(this.request.body.whole_word), - expires_in: this.request.body.expires_in, - }; - const data = await this.client.updateFilter(this.request.params.id, this.request.body.phrase, this.request.body.context, options); - return convertFilter(data.data); - } + fastify.get('/v1/filters/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public async rmFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.deleteFilter(this.request.params.id); - return data.data; + const client = this.clientService.getClient(_request); + const data = await client.getFilter(_request.params.id); + const response = convertFilter(data.data); + + return reply.send(response); + }); + + fastify.post('/v1/filters', async (_request, reply) => { + if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); + + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; + + const client = this.clientService.getClient(_request); + const data = await client.createFilter(_request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); + + return reply.send(response); + }); + + fastify.post('/v1/filters/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); + + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; + + const client = this.clientService.getClient(_request); + const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); + + return reply.send(response); + }); + + fastify.delete('/v1/filters/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.deleteFilter(_request.params.id); + + return reply.send(data.data); + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts new file mode 100644 index 0000000000..cfca5b1350 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import type { MiMeta } from '@/models/_.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { RoleService } from '@/core/RoleService.js'; +import type { FastifyInstance } from 'fastify'; +import type { MastodonEntity } from 'megalodon'; + +@Injectable() +export class ApiInstanceMastodon { + constructor( + @Inject(DI.meta) + private readonly meta: MiMeta, + + @Inject(DI.config) + private readonly config: Config, + + private readonly mastoConverters: MastodonConverters, + private readonly clientService: MastodonClientService, + private readonly roleService: RoleService, + ) {} + + public register(fastify: FastifyInstance): void { + fastify.get('/v1/instance', async (_request, reply) => { + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getInstance(); + const contact = this.meta.rootUser != null + ? await this.mastoConverters.convertAccount(this.meta.rootUser) + : null; + const roles = await this.roleService.getUserPolicies(me?.id ?? null); + + const instance = data.data; + const response: MastodonEntity.Instance = { + uri: this.config.host, + title: this.meta.name || 'Sharkey', + description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + email: instance.email || '', + version: `3.0.0 (compatible; Sharkey ${this.config.version}; like Akkoma)`, + urls: instance.urls, + stats: { + user_count: instance.stats.user_count, + status_count: instance.stats.status_count, + domain_count: instance.stats.domain_count, + }, + thumbnail: this.meta.backgroundImageUrl || '/static-assets/transparent.png', + languages: this.meta.langs, + registrations: !this.meta.disableRegistration || instance.registrations, + approval_required: this.meta.approvalRequiredForSignup, + invites_enabled: instance.registrations, + configuration: { + accounts: { + max_featured_tags: 20, + max_pinned_statuses: roles.pinLimit, + }, + statuses: { + max_characters: this.config.maxNoteLength, + max_media_attachments: 16, + characters_reserved_per_url: instance.uri.length, + }, + media_attachments: { + supported_mime_types: FILE_TYPE_BROWSERSAFE, + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 10, + max_characters_per_option: 150, + min_expiration: 50, + max_expiration: 2629746, + }, + reactions: { + max_reactions: 1, + }, + }, + contact_account: contact, + rules: instance.rules ?? [], + }; + + return reply.send(response); + }); + } +} 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 48a56138cf..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity } from 'megalodon'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import type { Config } from '@/config.js'; -import type { MiMeta } from '@/models/Meta.js'; - -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ -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: 150, - 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 index 14eee8565a..f6cc59e782 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -3,56 +3,82 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; -import { MiLocalUser } from '@/models/User.js'; -import { MastoConverters } from '@/server/api/mastodon/converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { Injectable } from '@nestjs/common'; +import { MastodonEntity } from 'megalodon'; +import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; +import { MastodonClientService } from '../MastodonClientService.js'; +import type { FastifyInstance } from 'fastify'; -export interface ApiNotifyMastodonRoute { +interface ApiNotifyMastodonRoute { Params: { id?: string, }, Querystring: TimelineArgs, } -export class ApiNotifyMastodon { +@Injectable() +export class ApiNotificationsMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, + private readonly clientService: MastodonClientService, ) {} - public async getNotifications() { - const data = await this.client.getNotifications(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map(async n => { - const converted = await this.mastoConverters.convertNotification(n, this.me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; + public register(fastify: FastifyInstance): void { + fastify.get('/v1/notifications', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const data = await client.getNotifications(parseTimelineArgs(request.query)); + const notifications = await Promise.all(data.data.map(n => this.mastoConverters.convertNotification(n, me))); + const response: MastodonEntity.Notification[] = []; + for (const notification of notifications) { + // Notifications for inaccessible notes will be null and should be ignored + if (!notification) continue; + + response.push(notification); + if (notification.type === 'reaction') { + response.push({ + ...notification, + type: 'favourite', + }); + } } - return converted; - })); - } - public async getNotification() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getNotification(this.request.params.id); - const converted = await this.mastoConverters.convertNotification(data.data, this.me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; - } - return converted; - } + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); - public async rmNotification() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.dismissNotification(this.request.params.id); - return data.data; - } + fastify.get('/v1/notification/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public async rmNotifications() { - const data = await this.client.dismissNotifications(); - return data.data; + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getNotification(_request.params.id); + const response = await this.mastoConverters.convertNotification(data.data, me); + + // Notifications for inaccessible notes will be null and should be ignored + if (!response) { + return reply.code(404).send({ + error: 'NOT_FOUND', + }); + } + + return reply.send(response); + }); + + fastify.post('/v1/notification/:id/dismiss', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.dismissNotification(_request.params.id); + + return reply.send(data.data); + }); + + fastify.post('/v1/notifications/clear', async (_request, reply) => { + const client = this.clientService.getClient(_request); + const data = await client.dismissNotifications(); + + return reply.send(data.data); + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 4850b4652f..c43d6cfc9a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -3,92 +3,188 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { MiLocalUser } from '@/models/User.js'; -import { MastoConverters } from '../converters.js'; -import { parseTimelineArgs, TimelineArgs } from '../timelineArgs.js'; -import Account = Entity.Account; -import Status = Entity.Status; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { Injectable } from '@nestjs/common'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js'; +import { MastodonConverters } from '../MastodonConverters.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; +import { ApiError } from '../../error.js'; +import type { FastifyInstance } from 'fastify'; +import type { Entity } from 'megalodon'; -export interface ApiSearchMastodonRoute { +interface ApiSearchMastodonRoute { Querystring: TimelineArgs & { - type?: 'accounts' | 'hashtags' | 'statuses'; + type?: string; q?: string; + resolve?: string; } } +@Injectable() export class ApiSearchMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, - private readonly BASE_URL: string, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, + private readonly clientService: MastodonClientService, ) {} - public async SearchV1() { - if (!this.request.query.q) throw new Error('Missing required property "q"'); - const query = parseTimelineArgs(this.request.query); - const data = await this.client.search(this.request.query.q, { type: this.request.query.type, ...query }); - return data.data; - } + public register(fastify: FastifyInstance): void { + fastify.get('/v1/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (!request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); - public async SearchV2() { - if (!this.request.query.q) throw new Error('Missing required property "q"'); - const query = parseTimelineArgs(this.request.query); - const type = this.request.query.type; - const acct = !type || type === 'accounts' ? await this.client.search(this.request.query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await this.client.search(this.request.query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await this.client.search(this.request.query.q, { type: 'hashtags', ...query }) : null; - return { - accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, this.me)) ?? []), - hashtags: tags?.data.hashtags ?? [], - }; - } + const type = request.query.type; + if (type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } - public async getStatusTrends() { - const data = await fetch(`${this.BASE_URL}/api/notes/featured`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - i: this.request.headers.authorization?.replace('Bearer ', ''), - }), - }) - .then(res => res.json() as Promise) - .then(data => data.map(status => this.mastoConverters.convertStatus(status, this.me))); - return Promise.all(data); - } + const { client, me } = await this.clientService.getAuthClient(request); - public async getSuggestions() { - 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: parseTimelineArgs(this.request.query).limit ?? 20, - origin: 'local', - sort: '+follower', - state: 'alive', - }), - }) - .then(res => res.json() as Promise) - .then(data => data.map((entry => ({ - source: 'global', - account: entry, - })))); - return Promise.all(data.map(async suggestion => { - suggestion.account = await this.mastoConverters.convertAccount(suggestion.account); - return suggestion; - })); + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } + + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const { data } = await client.search(request.query.q, { type, ...query }); + const response = { + ...data, + accounts: await Promise.all(data.accounts.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))), + statuses: await Promise.all(data.statuses.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))), + }; + + if (type === 'hashtags') { + attachOffsetPagination(request, reply, response.hashtags); + } else { + attachMinMaxPagination(request, reply, response[type]); + } + + return reply.send(response); + }); + + fastify.get('/v2/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + + const type = request.query.type; + if (type !== undefined && type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } + + const { client, me } = await this.clientService.getAuthClient(request); + + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } + + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const acct = !type || type === 'accounts' ? await client.search(request.query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await client.search(request.query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await client.search(request.query.q, { type: 'hashtags', ...query }) : null; + const response = { + accounts: await Promise.all(acct?.data.accounts.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)) ?? []), + hashtags: tags?.data.hashtags ?? [], + }; + + // Pagination hack, based on "best guess" expected behavior. + // Mastodon doesn't document this part at all! + const longestResult = [response.statuses, response.hashtags] + .reduce((longest: unknown[], current: unknown[]) => current.length > longest.length ? current : longest, response.accounts); + + // Ignore min/max pagination because how TF would that work with multiple result sets?? + // Offset pagination is the only possible option + attachOffsetPagination(request, reply, longestResult); + + return reply.send(response); + }); + + fastify.get('/v1/trends/statuses', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); + const res = await fetch(`${baseUrl}/api/notes/featured`, + { + method: 'POST', + headers: { + ...request.headers, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: '{}', + }); + + await verifyResponse(res); + + const data = await res.json() as Entity.Status[]; + const me = await this.clientService.getAuth(request); + const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); + }); + + fastify.get('/v2/suggestions', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); + const res = await fetch(`${baseUrl}/api/users`, + { + method: 'POST', + headers: { + ...request.headers, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + limit: parseTimelineArgs(request.query).limit ?? 20, + origin: 'local', + sort: '+follower', + state: 'alive', + }), + }); + + await verifyResponse(res); + + const data = await res.json() as Entity.Account[]; + const response = await Promise.all(data.map(async entry => { + return { + source: 'global', + account: await this.mastoConverters.convertAccount(entry), + }; + })); + + attachOffsetPagination(request, reply, response); + return reply.send(response); + }); } } + +async function verifyResponse(res: Response): Promise { + if (res.ok) return; + + const text = await res.text(); + + if (res.headers.get('content-type') === 'application/json') { + try { + const json = JSON.parse(text); + + if (json && typeof(json) === 'object') { + json.httpStatusCode = res.status; + return json; + } + } catch { /* ignore */ } + } + + // Response is not a JSON object; treat as string + throw new ApiError({ + code: 'INTERNAL_ERROR', + message: text || 'Internal error occurred. Please contact us if the error persists.', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + kind: 'server', + httpStatusCode: res.status, + }); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 4c49a6a293..7a058a0ed9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -4,12 +4,15 @@ */ import querystring, { ParsedUrlQueryInput } from 'querystring'; +import { Injectable } from '@nestjs/common'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/timelineArgs.js'; -import { AuthenticateService } from '@/server/api/AuthenticateService.js'; -import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; -import { getAccessToken, getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js'; +import { getNoteSummary } from '@/misc/get-note-summary.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { isPureRenote } from '@/misc/is-renote.js'; +import { convertAttachment, convertPoll, MastodonConverters } from '../MastodonConverters.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -18,167 +21,124 @@ function normalizeQuery(data: Record) { return querystring.parse(str); } +@Injectable() export class ApiStatusMastodon { constructor( - private readonly fastify: FastifyInstance, - private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, - private readonly authenticateService: AuthenticateService, - private readonly mastodon: MastodonApiServerService, + private readonly mastoConverters: MastodonConverters, + private readonly clientService: MastodonClientService, + private readonly mastodonDataService: MastodonDataService, ) {} - public getStatus() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } - }); - } + public register(fastify: FastifyInstance): void { + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public getStatusSource() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getStatusSource(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/source`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } - }); - } + const { client, me } = await this.clientService.getAuthClient(_request); + const note = await this.mastodonDataService.requireNote(_request.params.id, me, { user: true, renote: { user: true } }); - public getContext() { - this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); - const ancestors = await Promise.all(data.ancestors.map(async status => await this.mastoConverters.convertStatus(status, me))); - const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status, me))); - reply.send({ ancestors, descendants }); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/context`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } - }); - } + // Unpack renote for Discord, otherwise the preview breaks + const appearNote = (isPureRenote(note) && _request.headers['user-agent']?.match(/\bDiscordbot\//)) + ? note.renote as NonNullable + : note; - public getHistory() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const [user] = await this.authenticateService.authenticate(getAccessToken(_request.headers.authorization)); - const edits = await this.mastoConverters.getEdits(_request.params.id, user); - reply.send(edits); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/history`, data); - reply.code(401).send(data); - } - }); - } + const data = await client.getStatus(appearNote.id); + const response = await this.mastoConverters.convertStatus(data.data, me, { note: appearNote, user: appearNote.user }); - public getReblogged() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getStatusRebloggedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/reblogged_by`, data); - reply.code(401).send(data); + // Fixup - Discord ignores CWs and renders the entire post. + if (response.sensitive && _request.headers['user-agent']?.match(/\bDiscordbot\//)) { + response.content = getNoteSummary(data.data satisfies Packed<'Note'>); + response.media_attachments = []; + response.in_reply_to_id = null; + response.in_reply_to_account_id = null; + response.reblog = null; + response.quote = null; } - }); - } - public getFavourites() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getStatusFavouritedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/favourited_by`, data); - reply.code(401).send(data); - } + return reply.send(response); }); - } - public getMedia() { - this.fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getMedia(_request.params.id); - reply.send(convertAttachment(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/media/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getStatusSource(_request.params.id); + + return reply.send(data.data); }); - } - public getPoll() { - this.fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getPoll(_request.params.id); - reply.send(convertPoll(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/polls/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); + const ancestors = await Promise.all(data.ancestors.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + const descendants = await Promise.all(data.descendants.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + const response = { ancestors, descendants }; + + return reply.send(response); }); - } - public votePoll() { - this.fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' }); - const data = await client.votePoll(_request.params.id, _request.body.choices); - reply.send(convertPoll(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/polls/${_request.params.id}/votes`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const user = await this.clientService.getAuth(_request); + const edits = await this.mastoConverters.getEdits(_request.params.id, user); + + return reply.send(edits); }); - } - public postStatus() { - this.fastify.post<{ + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getStatusRebloggedBy(_request.params.id); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + return reply.send(response); + }); + + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getStatusFavouritedBy(_request.params.id); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + return reply.send(response); + }); + + fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getMedia(_request.params.id); + const response = convertAttachment(data.data); + + return reply.send(response); + }); + + fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getPoll(_request.params.id); + const response = convertPoll(data.data); + + return reply.send(response); + }); + + fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.choices) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "choices"' }); + + const client = this.clientService.getClient(_request); + const data = await client.votePoll(_request.params.id, _request.body.choices); + const response = convertPoll(data.data); + + return reply.send(response); + }); + + fastify.post<{ Body: { media_ids?: string[], poll?: { @@ -202,63 +162,58 @@ export class ApiStatusMastodon { } }>('/v1/statuses', async (_request, reply) => { let body = _request.body; - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) - ) { - body = normalizeQuery(body); - } - const text = 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') { - const id = body.in_reply_to_id; - const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter(e => e.me)[0].name; - const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); - } - if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - - if (body.poll && !body.poll.options) { - return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); - } - if (body.poll && !body.poll.expires_in) { - return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); - } - - const options = { - ...body, - sensitive: toBoolean(body.sensitive), - poll: body.poll ? { - options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion - expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion - multiple: toBoolean(body.poll.multiple), - hide_totals: toBoolean(body.poll.hide_totals), - } : undefined, - }; - - const data = await client.postStatus(text, options); - reply.send(await this.mastoConverters.convertStatus(data.data as Entity.Status, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/statuses', data); - reply.code(401).send(data); + if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) + ) { + body = normalizeQuery(body); } - }); - } + const text = body.status ??= ' '; + const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); + const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); + const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); - public updateStatus() { - this.fastify.put<{ + const { client, me } = await this.clientService.getAuthClient(_request); + if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) { + const a = await client.createEmojiReaction( + body.in_reply_to_id, + removed, + ); + return reply.send(a.data); + } + if (body.in_reply_to_id && removed === '/unreact') { + const id = body.in_reply_to_id; + const post = await client.getStatus(id); + const react = post.data.emoji_reactions.filter((e: Entity.Emoji) => e.me)[0].name; + const data = await client.deleteEmojiReaction(id, react); + return reply.send(data.data); + } + body.media_ids ??= undefined; + if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; + + if (body.poll && !body.poll.options) { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.options"' }); + } + if (body.poll && !body.poll.expires_in) { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.expires_in"' }); + } + + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.postStatus(text, options); + const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); + + return reply.send(response); + }); + + fastify.put<{ Params: { id: string }, Body: { status?: string, @@ -273,201 +228,138 @@ export class ApiStatusMastodon { }, } }>('/v1/statuses/:id', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const body = _request.body; + const { client, me } = await this.clientService.getAuthClient(_request); + const body = _request.body; - if (!body.media_ids || !body.media_ids.length) { - body.media_ids = undefined; - } - - const options = { - ...body, - sensitive: toBoolean(body.sensitive), - poll: body.poll ? { - options: body.poll.options, - expires_in: toInt(body.poll.expires_in), - multiple: toBoolean(body.poll.multiple), - hide_totals: toBoolean(body.poll.hide_totals), - } : undefined, - }; - - const data = await client.editStatus(_request.params.id, options); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}`, data); - reply.code(401).send(data); + if (!body.media_ids || !body.media_ids.length) { + body.media_ids = undefined; } + + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options, + expires_in: toInt(body.poll.expires_in), + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.editStatus(_request.params.id, options); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public addFavourite() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.createEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/favorite`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.createEmojiReaction(_request.params.id, '❤'); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public rmFavourite() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.deleteEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/unfavorite`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.deleteEmojiReaction(_request.params.id, '❤'); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public reblogStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.reblogStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/reblog`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.reblogStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public unreblogStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.unreblogStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/unreblog`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unreblogStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public bookmarkStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.bookmarkStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/bookmark`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.bookmarkStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public unbookmarkStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.unbookmarkStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/unbookmark`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unbookmarkStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - public pinStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.pinStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/pin`, data); - reply.code(401).send(data); - } + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.pinStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public unpinStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.unpinStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/unpin`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unpinStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public reactStatus() { - this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/react/${_request.params.name}`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.createEmojiReaction(_request.params.id, _request.params.name); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public unreactStatus() { - this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/unreact/${_request.params.name}`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); + const response = await this.mastoConverters.convertStatus(data.data, me); + + return reply.send(response); }); - } - public deleteStatus() { - this.fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.deleteStatus(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/statuses/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.deleteStatus(_request.params.id); + + return reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 1a732d62de..b2f7b18dc9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -3,232 +3,156 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { convertList, MastoConverters } from '../converters.js'; -import { getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; -import { parseTimelineArgs, TimelineArgs, toBoolean } from '../timelineArgs.js'; +import { Injectable } from '@nestjs/common'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; +import { convertList, MastodonConverters } from '../MastodonConverters.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; +@Injectable() export class ApiTimelineMastodon { constructor( - private readonly fastify: FastifyInstance, - private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, - private readonly mastodon: MastodonApiServerService, + private readonly clientService: MastodonClientService, + private readonly mastoConverters: MastodonConverters, ) {} - public getTL() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = toBoolean(_request.query.local) - ? await client.getLocalTimeline(parseTimelineArgs(_request.query)) - : await client.getPublicTimeline(parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/timelines/public', data); - reply.code(401).send(data); - } - }); - } + public register(fastify: FastifyInstance): void { + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = toBoolean(request.query.local) + ? await client.getLocalTimeline(query) + : await client.getPublicTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); - public getHomeTl() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getHomeTimeline(parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/timelines/home', data); - reply.code(401).send(data); - } + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getTagTl() { - this.fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - try { - if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getTagTimeline(_request.params.hashtag, parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/timelines/tag/${_request.params.hashtag}`, data); - reply.code(401).send(data); - } + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getHomeTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getListTL() { - this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getListTimeline(_request.params.id, parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/timelines/list/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => { + if (!request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); + + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getTagTimeline(request.params.hashtag, query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getConversations() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { - try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getConversationTimeline(parseTimelineArgs(_request.query)); - const conversations = await Promise.all(data.data.map(async (conversation: Entity.Conversation) => await this.mastoConverters.convertConversation(conversation, me))); - reply.send(conversations); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/conversations', data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getListTimeline(request.params.id, query); + const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getList() { - this.fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.getList(_request.params.id); - reply.send(convertList(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getConversationTimeline(query); + const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public getLists() { - this.fastify.get('/v1/lists', async (_request, reply) => { - try { - const BASE_URL = `${_request.protocol}://${_request.host}`; - 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) { - const data = getErrorData(e); - this.logger.error('GET /v1/lists', data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getList(_request.params.id); + const response = convertList(data.data); + + return reply.send(response); }); - } - public getListAccounts() { - this.fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.getAccountsInList(_request.params.id, _request.query); - const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(accounts); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + fastify.get('/v1/lists', async (request, reply) => { + const client = this.clientService.getClient(request); + const data = await client.getLists(); + const response = data.data.map((list: Entity.List) => convertList(list)); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public addListAccount() { - this.fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(request); + const data = await client.getAccountsInList(request.params.id, parseTimelineArgs(request.query)); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + attachMinMaxPagination(request, reply, response); + return reply.send(response); }); - } - public rmListAccount() { - this.fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); + + return reply.send(data.data); }); - } - public createList() { - this.fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { - try { - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.createList(_request.body.title); - reply.send(convertList(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/lists', data); - reply.code(401).send(data); - } + fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); + + return reply.send(data.data); }); - } - public updateList() { - this.fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const data = await client.updateList(_request.params.id, _request.body.title); - reply.send(convertList(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`PUT /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { + if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' }); + + const client = this.clientService.getClient(_request); + const data = await client.createList(_request.body.title); + const response = convertList(data.data); + + return reply.send(response); }); - } - public deleteList() { - this.fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - await client.deleteList(_request.params.id); - reply.send({}); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' }); + + const client = this.clientService.getClient(_request); + const data = await client.updateList(_request.params.id, _request.body.title); + const response = convertList(data.data); + + return reply.send(response); + }); + + fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + await client.deleteList(_request.params.id); + + return reply.send({}); }); } } diff --git a/packages/backend/src/server/api/mastodon/pagination.ts b/packages/backend/src/server/api/mastodon/pagination.ts new file mode 100644 index 0000000000..2cf24cfb24 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/pagination.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FastifyReply, FastifyRequest } from 'fastify'; +import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js'; + +interface AnyEntity { + readonly id: string; +} + +/** + * Attaches Mastodon's pagination headers to a response that is paginated by min_id / max_id parameters. + * Results must be sorted, but can be in ascending or descending order. + * Attached headers will always be in descending order. + * + * @param request Fastify request object + * @param reply Fastify reply object + * @param results Results array, ordered in ascending or descending order + */ +export function attachMinMaxPagination(request: FastifyRequest, reply: FastifyReply, results: AnyEntity[]): void { + // No results, nothing to do + if (!hasItems(results)) return; + + // "next" link - older results + const oldest = findOldest(results); + const nextUrl = createPaginationUrl(request, { max_id: oldest }); // Next page (older) has IDs less than the oldest of this page + const next = `<${nextUrl}>; rel="next"`; + + // "prev" link - newer results + const newest = findNewest(results); + const prevUrl = createPaginationUrl(request, { min_id: newest }); // Previous page (newer) has IDs greater than the newest of this page + const prev = `<${prevUrl}>; rel="prev"`; + + // https://docs.joinmastodon.org/api/guidelines/#pagination + const link = `${next}, ${prev}`; + reply.header('link', link); +} + +/** + * Attaches Mastodon's pagination headers to a response that is paginated by limit / offset parameters. + * Results must be sorted, but can be in ascending or descending order. + * Attached headers will always be in descending order. + * + * @param request Fastify request object + * @param reply Fastify reply object + * @param results Results array, ordered in ascending or descending order + */ +export function attachOffsetPagination(request: FastifyRequest, reply: FastifyReply, results: unknown[]): void { + const links: string[] = []; + + // Find initial offset + const offset = findOffset(request); + const limit = findLimit(request); + + // "next" link - older results + if (hasItems(results)) { + const oldest = offset + results.length; + const nextUrl = createPaginationUrl(request, { offset: oldest }); // Next page (older) has entries less than the oldest of this page + links.push(`<${nextUrl}>; rel="next"`); + } + + // "prev" link - newer results + // We can only paginate backwards if a limit is specified + if (limit) { + // Make sure we don't cross below 0, as that will produce an API error + if (limit <= offset) { + const newest = offset - limit; + const prevUrl = createPaginationUrl(request, { offset: newest }); // Previous page (newer) has entries greater than the newest of this page + links.push(`<${prevUrl}>; rel="prev"`); + } else { + const prevUrl = createPaginationUrl(request, { offset: 0, limit: offset }); // Previous page (newer) has entries greater than the newest of this page + links.push(`<${prevUrl}>; rel="prev"`); + } + } + + // https://docs.joinmastodon.org/api/guidelines/#pagination + if (links.length > 0) { + const link = links.join(', '); + reply.header('link', link); + } +} + +function hasItems(items: T[]): items is [T, ...T[]] { + return items.length > 0; +} + +function findOffset(request: FastifyRequest): number { + if (typeof(request.query) !== 'object') return 0; + + const query = request.query as Record; + if (!query.offset) return 0; + + if (Array.isArray(query.offset)) { + const offsets = query.offset + .map(o => parseInt(o)) + .filter(o => !isNaN(o)); + const offset = Math.max(...offsets); + return isNaN(offset) ? 0 : offset; + } + + const offset = parseInt(query.offset); + return isNaN(offset) ? 0 : offset; +} + +function findLimit(request: FastifyRequest): number | null { + if (typeof(request.query) !== 'object') return null; + + const query = request.query as Record; + if (!query.limit) return null; + + if (Array.isArray(query.limit)) { + const limits = query.limit + .map(l => parseInt(l)) + .filter(l => !isNaN(l)); + const limit = Math.max(...limits); + return isNaN(limit) ? null : limit; + } + + const limit = parseInt(query.limit); + return isNaN(limit) ? null : limit; +} + +function findOldest(items: [AnyEntity, ...AnyEntity[]]): string { + const first = items[0].id; + const last = items[items.length - 1].id; + + return isOlder(first, last) ? first : last; +} + +function findNewest(items: [AnyEntity, ...AnyEntity[]]): string { + const first = items[0].id; + const last = items[items.length - 1].id; + + return isOlder(first, last) ? last : first; +} + +function isOlder(a: string, b: string): boolean { + if (a === b) return false; + + if (a.length !== b.length) { + return a.length < b.length; + } + + return a < b; +} + +function createPaginationUrl(request: FastifyRequest, data: { + min_id?: string; + max_id?: string; + offset?: number; + limit?: number; +}): string { + const baseUrl = getBaseUrl(request); + const requestUrl = new URL(request.url, baseUrl); + + // Remove any existing pagination + requestUrl.searchParams.delete('min_id'); + requestUrl.searchParams.delete('max_id'); + requestUrl.searchParams.delete('since_id'); + requestUrl.searchParams.delete('offset'); + + if (data.min_id) requestUrl.searchParams.set('min_id', data.min_id); + if (data.max_id) requestUrl.searchParams.set('max_id', data.max_id); + if (data.offset) requestUrl.searchParams.set('offset', String(data.offset)); + if (data.limit) requestUrl.searchParams.set('limit', String(data.limit)); + + return requestUrl.href; +} diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index 82ee0f47d7..85d1fd0bce 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -210,9 +210,15 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { spec.paths['/' + endpoint.name] = { ...(endpoint.meta.allowGet ? { - get: info, + get: { + ...info, + operationId: 'get___' + info.operationId, + }, } : {}), - post: info, + post: { + ...info, + operationId: 'post___' + info.operationId, + }, }; } diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 83c5fcdf52..13934628a8 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -20,6 +20,8 @@ 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 { ChatUserChannelService } from './channels/chat-user.js'; +import { ChatRoomChannelService } from './channels/chat-room.js'; import { ReversiChannelService } from './channels/reversi.js'; import { ReversiGameChannelService } from './channels/reversi-game.js'; import { type MiChannelService } from './channel.js'; @@ -42,6 +44,8 @@ export class ChannelsService { private serverStatsChannelService: ServerStatsChannelService, private queueStatsChannelService: QueueStatsChannelService, private adminChannelService: AdminChannelService, + private chatUserChannelService: ChatUserChannelService, + private chatRoomChannelService: ChatRoomChannelService, private reversiChannelService: ReversiChannelService, private reversiGameChannelService: ReversiGameChannelService, ) { @@ -65,6 +69,8 @@ export class ChannelsService { case 'serverStats': return this.serverStatsChannelService; case 'queueStats': return this.queueStatsChannelService; case 'admin': return this.adminChannelService; + case 'chatUser': return this.chatUserChannelService; + case 'chatRoom': return this.chatRoomChannelService; case 'reversi': return this.reversiChannelService; case 'reversiGame': return this.reversiGameChannelService; diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index e98e2a2f3f..0ee7078eb2 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -7,7 +7,6 @@ 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'; @@ -23,6 +22,7 @@ import type { EventEmitter } from 'events'; import type Channel from './channel.js'; const MAX_CHANNELS_PER_CONNECTION = 32; +const MAX_SUBSCRIPTIONS_PER_CONNECTION = 512; /** * Main stream connection @@ -31,27 +31,23 @@ const MAX_CHANNELS_PER_CONNECTION = 32; export default class Connection { public user?: MiUser; public token?: MiAccessToken; - private rateLimiter?: () => Promise; private wsConnection: WebSocket.WebSocket; public subscriber: StreamEventEmitter; - private channels: Channel[] = []; - private subscribingNotes: Partial> = {}; - private cachedNotes: Packed<'Note'>[] = []; + private channels = new Map(); + private subscribingNotes = new Map(); public userProfile: MiUserProfile | null = null; - public following: Record | undefined> = {}; + public following: Map> = new Map(); 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; - private activeRateLimitRequests = 0; private closingConnection = false; private logger: Logger; constructor( private channelsService: ChannelsService, - private noteReadService: NoteReadService, private notificationService: NotificationService, private cacheService: CacheService, private channelFollowingService: ChannelFollowingService, @@ -60,11 +56,10 @@ export default class Connection { user: MiUser | null | undefined, token: MiAccessToken | null | undefined, private ip: string, - rateLimiter: () => Promise, + private readonly rateLimiter: () => Promise, ) { if (user) this.user = user; if (token) this.token = token; - if (rateLimiter) this.rateLimiter = rateLimiter; this.logger = loggerService.getLogger('streaming', 'coral'); } @@ -121,25 +116,13 @@ export default class Connection { if (this.closingConnection) return; - if (this.rateLimiter) { - // this 4096 should match the `max` of the `rateLimiter`, see - // StreamingApiServerService - if (this.activeRateLimitRequests <= 4096) { - this.activeRateLimitRequests++; - const shouldRateLimit = await this.rateLimiter(); - this.activeRateLimitRequests--; + // The rate limit is very high, so we can safely disconnect any client that hits it. + if (await this.rateLimiter()) { + this.logger.warn(`Closing a connection from ${this.ip} (user=${this.user?.id}}) due to an excessive influx of messages.`); - if (shouldRateLimit) return; - if (this.closingConnection) return; - } else { - let connectionInfo = `IP ${this.ip}`; - if (this.user) connectionInfo += `, user ID ${this.user.id}`; - - this.logger.warn(`Closing a connection (${connectionInfo}) due to an excessive influx of messages.`); - this.closingConnection = true; - this.wsConnection.close(1008, 'Please stop spamming the streaming API.'); - return; - } + this.closingConnection = true; + this.wsConnection.close(1008, 'Disconnected - too many requests'); + return; } try { @@ -154,7 +137,7 @@ export default class Connection { 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 'sr': this.onSubscribeNote(body); break; case 'unsubNote': this.onUnsubscribeNote(body); break; case 'un': this.onUnsubscribeNote(body); break; // alias case 'connect': this.onChannelConnectRequested(body); break; @@ -169,39 +152,6 @@ export default class Connection { 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: JsonValue | undefined) { - if (!isJsonObject(body)) return; - 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: JsonValue | undefined) { this.notificationService.readAllNotification(this.user!.id); @@ -215,9 +165,19 @@ export default class Connection { if (!isJsonObject(payload)) return; if (!payload.id || typeof payload.id !== 'string') return; - const current = this.subscribingNotes[payload.id] ?? 0; + const current = this.subscribingNotes.get(payload.id) ?? 0; const updated = current + 1; - this.subscribingNotes[payload.id] = updated; + this.subscribingNotes.set(payload.id, updated); + + // Limit the number of distinct notes that can be subscribed to. + while (this.subscribingNotes.size > MAX_SUBSCRIPTIONS_PER_CONNECTION) { + // Map maintains insertion order, so first key is always the oldest + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const oldestKey = this.subscribingNotes.keys().next().value!; + + this.subscribingNotes.delete(oldestKey); + this.subscriber.off(`noteStream:${oldestKey}`, this.onNoteStreamMessage); + } if (updated === 1) { this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); @@ -232,12 +192,12 @@ export default class Connection { if (!isJsonObject(payload)) return; if (!payload.id || typeof payload.id !== 'string') return; - const current = this.subscribingNotes[payload.id]; + const current = this.subscribingNotes.get(payload.id); if (current == null) return; const updated = current - 1; - this.subscribingNotes[payload.id] = updated; + this.subscribingNotes.set(payload.id, updated); if (updated <= 0) { - delete this.subscribingNotes[payload.id]; + this.subscribingNotes.delete(payload.id); this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage); } } @@ -304,7 +264,11 @@ export default class Connection { */ @bindThis public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { - if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) { + if (this.channels.has(id)) { + this.disconnectChannel(id); + } + + if (this.channels.size >= MAX_CHANNELS_PER_CONNECTION) { return; } @@ -320,12 +284,16 @@ export default class Connection { } // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 - if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) { - return; + if (channelService.shouldShare) { + for (const c of this.channels.values()) { + if (c.chName === channel) { + return; + } + } } const ch: Channel = channelService.create(id, this); - this.channels.push(ch); + this.channels.set(ch.id, ch); ch.init(params ?? {}); if (pong) { @@ -341,11 +309,11 @@ export default class Connection { */ @bindThis public disconnectChannel(id: string) { - const channel = this.channels.find(c => c.id === id); + const channel = this.channels.get(id); if (channel) { if (channel.dispose) channel.dispose(); - this.channels = this.channels.filter(c => c.id !== id); + this.channels.delete(id); } } @@ -360,7 +328,7 @@ export default class Connection { if (typeof data.type !== 'string') return; if (typeof data.body === 'undefined') return; - const channel = this.channels.find(c => c.id === data.id); + const channel = this.channels.get(data.id); if (channel != null && channel.onMessage != null) { channel.onMessage(data.type, data.body); } @@ -372,8 +340,15 @@ export default class Connection { @bindThis public dispose() { if (this.fetchIntervalId) clearInterval(this.fetchIntervalId); - for (const c of this.channels.filter(c => c.dispose)) { + for (const c of this.channels.values()) { if (c.dispose) c.dispose(); } + for (const k of this.subscribingNotes.keys()) { + this.subscriber.off(`noteStream:${k}`, this.onNoteStreamMessage); + } + + this.fetchIntervalId = null; + this.channels.clear(); + this.subscribingNotes.clear(); } } diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 7a6193ccfc..40ad454adb 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -61,12 +61,30 @@ export default abstract class Channel { return this.connection.subscriber; } + /** + * Checks if a note is visible to the current user *excluding* blocks and mutes. + */ + protected isNoteVisibleToMe(note: Packed<'Note'>): boolean { + if (note.visibility === 'public') return true; + if (note.visibility === 'home') return true; + if (!this.user) return false; + if (this.user.id === note.userId) return true; + if (note.visibility === 'followers') { + return this.following.has(note.userId); + } + if (!note.visibleUserIds) return false; + return note.visibleUserIds.includes(this.user.id); + } + /* * ミュートとブロックされてるを処理する */ protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean { + // Ignore notes that require sign-in + if (note.user.requireSigninToViewContents && !this.user) return true; + // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return true; + if (isInstanceMuted(note, this.userMutedInstances) && !this.following.has(note.userId)) return true; // 流れてきたNoteがミュートしているユーザーが関わる if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; @@ -79,9 +97,23 @@ export default abstract class Channel { // If it's a boost (pure renote) then we need to check the target as well if (isPackedPureRenote(note) && note.renote && this.isNoteMutedOrBlocked(note.renote)) return true; + // Hide silenced notes + if (note.user.isSilenced || note.user.instance?.isSilenced) { + if (this.user == null) return true; + if (this.user.id === note.userId) return false; + if (!this.following.has(note.userId)) return true; + } + + // TODO muted threads + return false; } + /** + * This function modifies {@link note}, please make sure it has been shallow cloned. + * See Dakkar's comment of {@link assignMyReaction} for more + * @param note The note to change + */ protected async hideNote(note: Packed<'Note'>): Promise { if (note.renote) { await this.hideNote(note.renote); @@ -101,8 +133,8 @@ export default abstract class Channel { this.noteEntityService = noteEntityService; } - public send(payload: { type: string, body: JsonValue }): void - public send(type: string, payload: JsonValue): void + public send(payload: { type: string, body: JsonValue }): void; + public send(type: string, payload: JsonValue): void; @bindThis public send(typeOrPayload: { type: string, body: JsonValue } | string, payload?: JsonValue) { const type = payload === undefined ? (typeOrPayload as { type: string, body: JsonValue }).type : (typeOrPayload as string); @@ -122,7 +154,6 @@ export default abstract class Channel { public onMessage?(type: string, body: JsonValue): void; public async assignMyReaction(note: Packed<'Note'>): Promise> { - let changed = false; // StreamingApiServerService creates a single EventEmitter per server process, // so a new note arriving from redis gets de-serialised once per server process, // and then that single object is passed to all active channels on each connection. @@ -133,7 +164,6 @@ export default abstract class Channel { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); if (myReaction) { - changed = true; clonedNote.renote = { ...note.renote }; clonedNote.renote.myReaction = myReaction; } @@ -141,7 +171,6 @@ export default abstract class Channel { if (note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) { const myReaction = await this.noteEntityService.populateMyReaction(note.renote.reply, this.user.id); if (myReaction) { - changed = true; clonedNote.renote = { ...note.renote }; clonedNote.renote.reply = { ...note.renote.reply }; clonedNote.renote.reply.myReaction = myReaction; @@ -151,12 +180,11 @@ export default abstract class Channel { if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) { const myReaction = await this.noteEntityService.populateMyReaction(note.reply, this.user.id); if (myReaction) { - changed = true; clonedNote.reply = { ...note.reply }; clonedNote.reply.myReaction = myReaction; } } - return changed ? clonedNote : note; + return clonedNote; } } @@ -165,4 +193,4 @@ export type MiChannelService = { 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/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index a73d158b7f..0974dbdb25 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -43,8 +43,6 @@ class AntennaChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - this.connection.cacheNote(note); - this.send('note', note); } else { this.send(data.type, data.body); diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index 5ebbdcbb86..72f719b411 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -5,13 +5,12 @@ import { Injectable } from '@nestjs/common'; 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 { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { UtilityService } from '@/core/UtilityService.js'; import Channel, { MiChannelService } from '../channel.js'; class BubbleTimelineChannel extends Channel { @@ -21,11 +20,10 @@ class BubbleTimelineChannel extends Channel { private withRenotes: boolean; private withFiles: boolean; private withBots: boolean; - private instance: MiMeta; constructor( - private metaService: MetaService, private roleService: RoleService, + private readonly utilityService: UtilityService, noteEntityService: NoteEntityService, id: string, @@ -42,7 +40,6 @@ class BubbleTimelineChannel extends Channel { 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); @@ -50,24 +47,40 @@ class BubbleTimelineChannel extends Channel { @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.user.host != null && this.instance.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return; - + if (note.visibility !== 'public') return; if (note.channelId != null) return; - - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; + if (!this.utilityService.isBubbledHost(note.user.host)) return; if (this.isNoteMutedOrBlocked(note)) return; + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } + } + + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } + const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } @@ -85,17 +98,17 @@ export class BubbleTimelineChannelService implements MiChannelService { public readonly kind = BubbleTimelineChannel.kind; constructor( - private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, + private readonly utilityService: UtilityService, ) { } @bindThis public create(id: string, connection: Channel['connection']): BubbleTimelineChannel { return new BubbleTimelineChannel( - this.metaService, this.roleService, + this.utilityService, 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 ec0bc7e13a..65fb8d67cb 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -52,8 +52,6 @@ class ChannelChannel extends Channel { const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts new file mode 100644 index 0000000000..648e407569 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/chat-room.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { JsonObject } from '@/misc/json-value.js'; +import { ChatService } from '@/core/ChatService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class ChatRoomChannel extends Channel { + public readonly chName = 'chatRoom'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:chat'; + private roomId: string; + + constructor( + private chatService: ChatService, + + id: string, + connection: Channel['connection'], + noteEntityService: NoteEntityService, + ) { + super(id, connection, noteEntityService); + } + + @bindThis + public async init(params: JsonObject) { + if (typeof params.roomId !== 'string') return; + this.roomId = params.roomId; + + this.subscriber.on(`chatRoomStream:${this.roomId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: GlobalEvents['chatRoom']['payload']) { + this.send(data.type, data.body); + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.roomId) { + this.chatService.readRoomChatMessage(this.user!.id, this.roomId); + } + break; + } + } + + @bindThis + public dispose() { + this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent); + } +} + +@Injectable() +export class ChatRoomChannelService implements MiChannelService { + public readonly shouldShare = ChatRoomChannel.shouldShare; + public readonly requireCredential = ChatRoomChannel.requireCredential; + public readonly kind = ChatRoomChannel.kind; + + constructor( + private chatService: ChatService, + private readonly noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): ChatRoomChannel { + return new ChatRoomChannel( + this.chatService, + id, + connection, + this.noteEntityService, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts new file mode 100644 index 0000000000..b37aef29d1 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/chat-user.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { JsonObject } from '@/misc/json-value.js'; +import { ChatService } from '@/core/ChatService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class ChatUserChannel extends Channel { + public readonly chName = 'chatUser'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:chat'; + private otherId: string; + + constructor( + private chatService: ChatService, + + id: string, + connection: Channel['connection'], + noteEntityService: NoteEntityService, + ) { + super(id, connection, noteEntityService); + } + + @bindThis + public async init(params: JsonObject) { + if (typeof params.otherId !== 'string') return; + this.otherId = params.otherId; + + this.subscriber.on(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: GlobalEvents['chatUser']['payload']) { + this.send(data.type, data.body); + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.otherId) { + this.chatService.readUserChatMessage(this.user!.id, this.otherId); + } + break; + } + } + + @bindThis + public dispose() { + this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); + } +} + +@Injectable() +export class ChatUserChannelService implements MiChannelService { + public readonly shouldShare = ChatUserChannel.shouldShare; + public readonly requireCredential = ChatUserChannel.requireCredential; + public readonly kind = ChatUserChannel.kind; + + constructor( + private chatService: ChatService, + private readonly noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): ChatUserChannel { + return new ChatUserChannel( + this.chatService, + id, + connection, + this.noteEntityService, + ); + } +} 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 72a8a8b156..5c73f637c7 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -48,23 +48,40 @@ class GlobalTimelineChannel extends Channel { @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.visibility !== 'public') return; if (note.channelId != null) return; - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; + + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } + } + + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 7c8df87721..f47a10f293 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -48,8 +48,6 @@ class HashtagChannel extends Channel { const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } 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 c87a21be82..c7062c0394 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -47,45 +47,35 @@ class HomeTimelineChannel extends Channel { if (!this.followingChannels.has(note.channelId)) return; } else { // その投稿のユーザーをフォローしていなかったら弾く - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; + if (!isMe && !this.following.has(note.userId)) 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 (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; 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) && reply.userId !== this.user!.id) return; - } else { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - // 純粋なリノート(引用リノートでないリノート)の場合 if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; if (note.renote.reply) { const reply = note.renote.reply; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + if (!this.isNoteVisibleToMe(reply)) return; } } - if (this.isNoteMutedOrBlocked(note)) return; - const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } 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 95b762e2b7..7cb64c9f89 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -62,47 +62,37 @@ class HybridTimelineChannel extends Channel { // フォローしているチャンネルの投稿 の場合だけ if (!( (note.channelId == null && isMe) || - (note.channelId == null && Object.hasOwn(this.following, 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 (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; if (note.reply) { 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) && reply.userId !== this.user!.id) return; - } else { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies && !this.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - // 純粋なリノート(引用リノートでないリノート)の場合 if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; if (note.renote.reply) { const reply = note.renote.reply; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + if (!this.isNoteVisibleToMe(reply)) return; } } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } 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 b9e0a4c234..4869d871d6 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -50,6 +50,8 @@ class LocalTimelineChannel extends Channel { @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; @@ -57,24 +59,32 @@ class LocalTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; + if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; + // 関係ない返信は除外 - if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { + if (note.reply) { const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (this.isNoteMutedOrBlocked(note)) return; + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 6b144e43e4..193907504a 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -32,27 +32,25 @@ class MainChannel extends Channel { switch (data.type) { case 'notification': { // Ignore notifications from instances the user has muted - if (isUserFromMutedInstance(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (isUserFromMutedInstance(data.body, this.userMutedInstances)) return; if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { + if (this.isNoteMutedOrBlocked(data.body.note)) return; + if (!this.isNoteVisibleToMe(data.body.id)) return; const note = await this.noteEntityService.pack(data.body.note.id, this.user, { detail: true, }); - this.connection.cacheNote(note); data.body.note = note; } break; } case 'mention': { - if (isInstanceMuted(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; - - if (this.userIdsWhoMeMuting.has(data.body.userId)) return; + if (this.isNoteMutedOrBlocked(data.body)) return; if (data.body.isHidden) { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true, }); - this.connection.cacheNote(note); data.body = note; } break; diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 14c4d96479..a3886618f1 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class RoleTimelineChannel extends Channel { @@ -40,7 +41,9 @@ class RoleTimelineChannel extends Channel { private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { if (data.type === 'note') { const note = data.body; + const isMe = this.user?.id === note.userId; + // TODO this should be cached if (!(await this.roleservice.isExplorable({ id: this.roleId }))) { return; } @@ -48,11 +51,28 @@ class RoleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } + } + + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } + const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } else { this.send(data.type, data.body); 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 d09a9b8d9f..4dae24a696 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -16,7 +16,8 @@ import Channel, { type MiChannelService } from '../channel.js'; class UserListChannel extends Channel { public readonly chName = 'userList'; public static shouldShare = false; - public static requireCredential = false as const; + public static requireCredential = true as const; + public static kind = 'read:account'; private listId: string; private membershipsMap: Record | undefined> = {}; private listUsersClock: NodeJS.Timeout; @@ -81,7 +82,7 @@ class UserListChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { - const isMe = this.user!.id === note.userId; + const isMe = this.user?.id === note.userId; // チャンネル投稿は無視する if (note.channelId) return; @@ -90,32 +91,32 @@ class UserListChannel extends Channel { 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 (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) 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 { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (this.isNoteMutedOrBlocked(note)) return; + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); - this.connection.cacheNote(clonedNote); - this.send('note', clonedNote); } @@ -130,7 +131,7 @@ class UserListChannel extends Channel { } @Injectable() -export class UserListChannelService implements MiChannelService { +export class UserListChannelService implements MiChannelService { public readonly shouldShare = UserListChannel.shouldShare; public readonly requireCredential = UserListChannel.requireCredential; public readonly kind = UserListChannel.kind; diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 6598aa9891..01ee451297 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -3,17 +3,14 @@ * 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 { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { getErrorData } from '@/server/api/mastodon/MastodonLogger.js'; +import { ServerUtilityService } from '@/server/ServerUtilityService.js'; import type { FastifyInstance } from 'fastify'; const kinds = [ @@ -51,19 +48,14 @@ const kinds = [ '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, + + private readonly mastodonClientService: MastodonClientService, + private readonly serverUtilityService: ServerUtilityService, ) { } // https://datatracker.ietf.org/doc/html/rfc8414.html @@ -100,105 +92,58 @@ export class OAuth2ProviderService { }); }); */ - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); + this.serverUtilityService.addMultipartFormDataContentType(fastify); + this.serverUtilityService.addFormUrlEncodedContentType(fastify); + this.serverUtilityService.addCORS(fastify); + this.serverUtilityService.addFlattenedQueryType(fastify); - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Access-Control-Allow-Origin', '*'); - done(); - }); + for (const url of ['/authorize', '/authorize/']) { + fastify.get<{ Querystring: Record }>(url, async (request, reply) => { + if (typeof(request.query.client_id) !== 'string') return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required query "client_id"' }); - fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; + const redirectUri = new URL(Buffer.from(request.query.client_id, 'base64').toString()); + redirectUri.searchParams.set('mastodon', 'true'); + if (request.query.state) redirectUri.searchParams.set('state', String(request.query.state)); + if (request.query.redirect_uri) redirectUri.searchParams.set('redirect_uri', String(request.query.redirect_uri)); + + return reply.redirect(redirectUri.toString()); }); - 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.post<{ Body?: Record, Querystring: Record }>('/token', async (request, reply) => { + const body = request.body ?? request.query; - 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") { + if (body.grant_type === 'client_credentials') { const ret = { access_token: uuid(), - token_type: "Bearer", - scope: "read", + 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; + return reply.send(ret); } + try { - const atData = await client.fetchAccessToken( - client_id, - body.client_secret, - token ? token : "", - ); + if (!body.client_secret) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required query "client_secret"' }); + + const clientId = body.client_id ? String(body.clientId) : null; + const secret = String(body.client_secret); + const code = body.code ? String(body.code) : ''; + + // TODO fetch the access token directly, then remove all oauth code from megalodon + const client = this.mastodonClientService.getClient(request); + const atData = await client.fetchAccessToken(clientId, secret, code); + const ret = { access_token: atData.accessToken, - token_type: "Bearer", - scope: body.scope || "read write follow push", - created_at: Math.floor(new Date().getTime() / 1000), + token_type: 'Bearer', + scope: atData.scope || body.scope || 'read write follow push', + created_at: atData.createdAt || Math.floor(new Date().getTime() / 1000), }; - reply.send(ret); - } catch (err: any) { - /* console.error(err); */ - reply.code(401).send(err.response.data); + return reply.send(ret); + } catch (e: unknown) { + const data = getErrorData(e); + return reply.code(401).send(data); } }); } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 3ed811e737..c40d042fa4 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -7,16 +7,12 @@ 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 as BullBoardFastifyAdapter } 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 htmlSafeJsonStringify from 'htmlescape'; @@ -226,65 +222,6 @@ export class ClientServerService { @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) => { - if (request.routeOptions.url == null) { - reply.code(404).send('Not found'); - return; - } - - // %71ueueとかでリクエストされたら困るため - const url = decodeURI(request.routeOptions.url); - if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) { - if (!url.startsWith(bullBoardPath + '/static/')) { - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - } - - 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 bullBoardServerAdapter = new BullBoardFastifyAdapter(); - - createBullBoard({ - queues: [ - this.systemQueue, - this.endedPollNotificationQueue, - this.deliverQueue, - this.inboxQueue, - this.dbQueue, - this.relationshipQueue, - this.objectStorageQueue, - this.userWebhookDeliverQueue, - this.systemWebhookDeliverQueue, - this.scheduleNotePostQueue, - ].map(q => new BullMQAdapter(q)), - serverAdapter: bullBoardServerAdapter, - }); - - bullBoardServerAdapter.setBasePath(bullBoardPath); - (fastify.register as any)(bullBoardServerAdapter.registerPlugin(), { prefix: bullBoardPath }); - //#endregion - fastify.register(fastifyView, { root: _dirname + '/views', engine: { @@ -490,7 +427,7 @@ export class ClientServerService { fastify.get('/robots.txt', async (request, reply) => { if (this.meta.robotsTxt) { reply.header('Content-Type', 'text/plain'); - return await reply.send(this.meta.robotsTxt); + return reply.send(this.meta.robotsTxt); } else { return await reply.sendFile('/robots.txt', staticAssets); } @@ -521,6 +458,7 @@ export class ClientServerService { url: this.config.url, title: this.meta.name ?? 'Sharkey', desc: this.meta.description, + customHead: this.config.customHtml.head, ...await this.generateCommonPugData(this.meta), ...data, }); @@ -620,7 +558,7 @@ export class ClientServerService { return await reply.view('user', { user, profile, me, - avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), + avatarUrl: _user.avatarUrl, sub: request.params.sub, ...await this.generateCommonPugData(this.meta), clientCtx: htmlSafeJsonStringify({ @@ -952,6 +890,7 @@ export class ClientServerService { return await reply.view('info-card', { version: this.config.version, host: this.config.host, + url: this.config.url, meta: this.meta, originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 664f21d308..a622ae7e34 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -15,7 +15,7 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { MfmService } from "@/core/MfmService.js"; -import { parse as mfmParse } from '@transfem-org/sfm-js'; +import { parse as mfmParse } from 'mfm-js'; @Injectable() export class FeedService { @@ -65,7 +65,7 @@ export class FeedService { 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), + image: (user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user), feedLinks: { json: `${author.link}.json`, atom: `${author.link}.atom`, diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index b8d7020598..71a142fc6f 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -4,9 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { summaly } from '@transfem-org/summaly'; -import { SummalyResult } from '@transfem-org/summaly/built/summary.js'; +import { summaly } from '@misskey-dev/summaly'; +import { SummalyResult } from '@misskey-dev/summaly/built/summary.js'; import * as Redis from 'ioredis'; +import { IsNull, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -14,33 +15,88 @@ 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 { MiMeta } from '@/models/Meta.js'; import { RedisKVCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; +import type { MiAccessToken, NotesRepository } from '@/models/_.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; +import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; +import { AuthenticateService, AuthenticationError } from '@/server/api/AuthenticateService.js'; +import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; +import type { MiLocalUser } from '@/models/User.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import { isRetryableError } from '@/misc/is-retryable-error.js'; +import * as Acct from '@/misc/acct.js'; +import { isNote } from '@/core/activitypub/type.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; +export type LocalSummalyResult = SummalyResult & { + haveNoteLocally?: boolean; + linkAttribution?: { + userId: string, + } +}; + +// Increment this to invalidate cached previews after a major change. +const cacheFormatVersion = 4; + +type PreviewRoute = { + Querystring: { + url?: string + lang?: string, + fetch?: string, + i?: string, + }, +}; + +type AuthArray = [user: MiLocalUser | null | undefined, app: MiAccessToken | null | undefined, actor: MiLocalUser | string]; + +// Up to 50 requests, then 10 / second (at 2 / 200ms rate) +const previewLimit: Keyed = { + key: '/url', + type: 'bucket', + size: 50, + dripSize: 2, + dripRate: 200, +}; + @Injectable() export class UrlPreviewService { private logger: Logger; - private previewCache: RedisKVCache; + private previewCache: RedisKVCache; constructor( @Inject(DI.config) private config: Config, @Inject(DI.redis) - private redisClient: Redis.Redis, + private readonly redisClient: Redis.Redis, @Inject(DI.meta) - private meta: MiMeta, + private readonly meta: MiMeta, + + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, private httpRequestService: HttpRequestService, private loggerService: LoggerService, - private utilityService: UtilityService, + private readonly utilityService: UtilityService, + private readonly apUtilityService: ApUtilityService, + private readonly apDbResolverService: ApDbResolverService, + private readonly remoteUserResolveService: RemoteUserResolveService, + private readonly apRequestService: ApRequestService, + private readonly systemAccountService: SystemAccountService, + private readonly apNoteService: ApNoteService, + private readonly authenticateService: AuthenticateService, + private readonly rateLimiterService: SkRateLimiterService, ) { this.logger = this.loggerService.getLogger('url-preview'); - this.previewCache = new RedisKVCache(this.redisClient, 'summaly', { + this.previewCache = new RedisKVCache(this.redisClient, 'summaly', { lifetime: 1000 * 60 * 60 * 24, // 1d memoryCacheLifetime: 1000 * 60 * 10, // 10m fetcher: () => { throw new Error('the UrlPreview cache should never fetch'); }, @@ -51,107 +107,206 @@ export class UrlPreviewService { @bindThis private wrap(url?: string | null): string | null { - return url != null - ? `${this.config.mediaProxy}/preview.webp?${query({ - url, - preview: '1', - })}` - : null; + if (url == null) return null; + + // Don't proxy our own media + if (this.utilityService.isUriLocal(url)) { + return url; + } + + // But proxy everything else! + const mediaQuery = query({ url, preview: '1' }); + return `${this.config.mediaProxy}/preview.webp?${mediaQuery}`; } @bindThis public async handle( - request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>, + request: FastifyRequest, reply: FastifyReply, - ): Promise { + ): Promise { + if (!this.meta.urlPreviewEnabled) { + // Tell crawlers not to index URL previews. + // https://developers.google.com/search/docs/crawling-indexing/block-indexing + reply.header('X-Robots-Tag', 'noindex'); + + return reply.code(403).send({ + error: { + message: 'URL preview is disabled', + code: 'URL_PREVIEW_DISABLED', + id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8', + }, + }); + } + const url = request.query.url; if (typeof url !== 'string' || !URL.canParse(url)) { reply.code(400); return; } + // Enforce HTTP(S) for input URLs + const urlScheme = this.utilityService.getUrlScheme(url); + if (urlScheme !== 'http:' && urlScheme !== 'https:') { + reply.code(400); + return; + } + const lang = request.query.lang; if (Array.isArray(lang)) { reply.code(400); return; } - if (!this.meta.urlPreviewEnabled) { - reply.code(403); - return { - error: new ApiError({ - message: 'URL preview is disabled', - code: 'URL_PREVIEW_DISABLED', - id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8', - }), - }; + // Strip out hash (anchor) + const urlObj = new URL(url); + if (urlObj.hash) { + urlObj.hash = ''; + const params = new URLSearchParams({ url: urlObj.href }); + if (lang) params.set('lang', lang); + const newUrl = `/url?${params.toString()}`; + + reply.redirect(newUrl, 301); + return; } - const host = new URL(url).host; - if (this.utilityService.isBlockedHost(this.meta.blockedHosts, host)) { - reply.code(403); - return { - error: new ApiError({ + // Check rate limit + const auth = await this.authenticate(request); + if (!await this.checkRateLimit(auth, reply)) { + return; + } + + if (this.utilityService.isBlockedHost(this.meta.blockedHosts, urlObj.host)) { + return reply.code(403).send({ + error: { message: 'URL is blocked', code: 'URL_PREVIEW_BLOCKED', id: '50294652-857b-4b13-9700-8e5c7a8deae8', - }), - }; + }, + }); } - const key = `${url}@${lang}`; - const cached = await this.previewCache.get(key); - if (cached !== undefined) { - this.logger.info(`Returning cache preview of ${key}`); - // Cache 7days - reply.header('Cache-Control', 'max-age=604800, immutable'); - - return cached; + const fetch = !!request.query.fetch; + if (fetch && !await this.checkFetchPermissions(auth, reply)) { + return; } - this.logger.info(this.meta.urlPreviewSummaryProxyUrl - ? `(Proxy) Getting preview of ${key} ...` - : `Getting preview of ${key} ...`); + const cacheKey = getCacheKey(url, lang); + if (await this.sendCachedPreview(cacheKey, reply, fetch)) { + return; + } try { - const summary = this.meta.urlPreviewSummaryProxyUrl + const summary: LocalSummalyResult = this.meta.urlPreviewSummaryProxyUrl ? await this.fetchSummaryFromProxy(url, this.meta, lang) : await this.fetchSummary(url, this.meta, lang); - this.logger.succ(`Got preview of ${url}: ${summary.title}`); + this.validateUrls(summary); - if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); + // Repeat check, since redirects are allowed. + if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) { + return reply.code(403).send({ + error: { + message: 'URL is blocked', + code: 'URL_PREVIEW_BLOCKED', + id: '50294652-857b-4b13-9700-8e5c7a8deae8', + }, + }); } - if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); - } + this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`); summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); - this.previewCache.set(key, summary); + // Summaly cannot always detect links to a fedi post, so do some additional tests to try and find missed cases. + if (!summary.activityPub) { + await this.inferActivityPubLink(summary); + } - // Cache 7days - reply.header('Cache-Control', 'max-age=604800, immutable'); + if (summary.activityPub && !summary.haveNoteLocally) { + // Avoid duplicate checks in case inferActivityPubLink already set this. + const exists = await this.noteExists(summary.activityPub, fetch); - return summary; + // Remove the AP flag if we encounter a permanent error fetching the note. + if (exists === false) { + summary.activityPub = null; + summary.haveNoteLocally = undefined; + } else { + summary.haveNoteLocally = exists ?? false; + } + } + + await this.validateLinkAttribution(summary); + + // Await this to avoid hammering redis when a bunch of URLs are fetched at once + await this.previewCache.set(cacheKey, summary); + + // Also cache the response URL in case of redirects + if (summary.url !== url) { + const responseCacheKey = getCacheKey(summary.url, lang); + await this.previewCache.set(responseCacheKey, summary); + } + + // Also cache the ActivityPub URL, if different from the others + if (summary.activityPub && summary.activityPub !== summary.url) { + const apCacheKey = getCacheKey(summary.activityPub, lang); + await this.previewCache.set(apCacheKey, summary); + } + + // Cache 1 day (matching redis), but only once we finalize the result + if (!summary.activityPub || summary.haveNoteLocally) { + reply.header('Cache-Control', 'public, max-age=86400'); + } + + return reply.code(200).send(summary); } catch (err) { - this.logger.warn(`Failed to get preview of ${url}: ${err}`); + this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`); - reply.code(422); - reply.header('Cache-Control', 'max-age=86400, immutable'); - return { - error: new ApiError({ + reply.header('Cache-Control', 'max-age=3600'); + return reply.code(422).send({ + error: { message: 'Failed to get preview', code: 'URL_PREVIEW_FAILED', id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', - }), - }; + }, + }); } } + private async sendCachedPreview(cacheKey: string, reply: FastifyReply, fetch: boolean): Promise { + const summary = await this.previewCache.get(cacheKey); + if (summary === undefined) { + return false; + } + + // Check if note has loaded since we last cached the preview + if (summary.activityPub && !summary.haveNoteLocally) { + // Avoid duplicate checks in case inferActivityPubLink already set this. + const exists = await this.noteExists(summary.activityPub, fetch); + + // Remove the AP flag if we encounter a permanent error fetching the note. + if (exists === false) { + summary.activityPub = null; + summary.haveNoteLocally = undefined; + } else { + summary.haveNoteLocally = exists ?? false; + } + + // Persist the result once we finalize the result + if (!summary.activityPub || summary.haveNoteLocally) { + await this.previewCache.set(cacheKey, summary); + } + } + + // Cache 1 day (matching redis), but only once we finalize the result + if (!summary.activityPub || summary.haveNoteLocally) { + reply.header('Cache-Control', 'public, max-age=86400'); + } + + reply.code(200).send(summary); + return true; + } + private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise { const agent = this.config.proxy ? { @@ -161,7 +316,7 @@ export class UrlPreviewService { : undefined; return summaly(url, { - followRedirects: false, + followRedirects: true, lang: lang ?? 'ja-JP', agent: agent, userAgent: meta.urlPreviewUserAgent ?? undefined, @@ -172,8 +327,10 @@ export class UrlPreviewService { } private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const proxy = meta.urlPreviewSummaryProxyUrl!; const queryStr = query({ + followRedirects: true, url: url, lang: lang ?? 'ja-JP', userAgent: meta.urlPreviewUserAgent ?? undefined, @@ -182,6 +339,237 @@ export class UrlPreviewService { contentLengthRequired: meta.urlPreviewRequireContentLength, }); - return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); + return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); + } + + private validateUrls(summary: LocalSummalyResult) { + const urlScheme = this.utilityService.getUrlScheme(summary.url); + if (urlScheme !== 'http:' && urlScheme !== 'https:') { + throw new Error(`unsupported scheme in preview URL: "${urlScheme}"`); + } + + if (summary.player.url) { + const playerScheme = this.utilityService.getUrlScheme(summary.player.url); + if (playerScheme !== 'http:' && playerScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: player URL has unsupported scheme "${playerScheme}"`); + summary.player.url = null; + } + } + + if (summary.icon) { + const iconScheme = this.utilityService.getUrlScheme(summary.icon); + if (iconScheme !== 'http:' && iconScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: icon URL has unsupported scheme "${iconScheme}"`); + summary.icon = null; + } + } + + if (summary.thumbnail) { + const thumbnailScheme = this.utilityService.getUrlScheme(summary.thumbnail); + if (thumbnailScheme !== 'http:' && thumbnailScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: thumbnail URL has unsupported scheme "${thumbnailScheme}"`); + summary.thumbnail = null; + } + } + + if (summary.activityPub) { + const activityPubScheme = this.utilityService.getUrlScheme(summary.activityPub); + if (activityPubScheme !== 'http:' && activityPubScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: ActivityPub URL has unsupported scheme "${activityPubScheme}"`); + summary.activityPub = null; + } + } + } + + private async inferActivityPubLink(summary: LocalSummalyResult) { + // Match canonical URI first. + // This covers local and remote links. + const isCanonicalUri = !!await this.apDbResolverService.getNoteFromApId(summary.url); + if (isCanonicalUri) { + summary.activityPub = summary.url; + summary.haveNoteLocally = true; + return; + } + + // Try public URL next. + // This is necessary for Mastodon and other software with a different public URL. + const urlMatches = await this.notesRepository.find({ + select: { + uri: true, + }, + where: { + url: summary.url, + uri: Not(IsNull()), + }, + }) as { uri: string }[]; + + // Older versions did not validate URL, so do it now to avoid impersonation. + const matchByUrl = urlMatches.find(({ uri }) => this.apUtilityService.haveSameAuthority(uri, summary.url)); + if (matchByUrl) { + summary.activityPub = matchByUrl.uri; + summary.haveNoteLocally = true; + return; + } + + // Finally, attempt a signed GET in case it's a direct link to an instance with authorized fetch. + const instanceActor = await this.systemAccountService.getInstanceActor(); + const remoteObject = await this.apRequestService.signedGet(summary.url, instanceActor).catch(() => null); + if (remoteObject && isNote(remoteObject) && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) { + summary.activityPub = remoteObject.id; + return; + } + } + + // true = exists, false = does not exist (permanently), null = does not exist (temporarily) + private async noteExists(uri: string, fetch = false): Promise { + try { + // Local note or cached remote note + if (await this.apDbResolverService.getNoteFromApId(uri)) { + return true; + } + + // Un-cached remote note + if (!fetch) { + return null; + } + + // Newly cached remote note + if (await this.apNoteService.resolveNote(uri)) { + return true; + } + + // Non-existent or deleted note + return false; + } catch (err) { + // Errors, including invalid notes and network errors + return isRetryableError(err) ? null : false; + } + } + + // Adapted from ApiCallService + private async authenticate(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>): Promise { + 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') { + return [undefined, undefined, getIpHash(request.ip)]; + } + + try { + const auth = await this.authenticateService.authenticate(token); + return [auth[0], auth[1], auth[0] ?? getIpHash(request.ip)]; + } catch (err) { + if (err instanceof AuthenticationError) { + return [undefined, undefined, getIpHash(request.ip)]; + } else { + throw err; + } + } + } + + private async validateLinkAttribution(summary: LocalSummalyResult) { + if (!summary.fediverseCreator) return; + if (!URL.canParse(summary.url)) return; + + const url = URL.parse(summary.url); + + const acct = Acct.parse(summary.fediverseCreator); + if (acct.host?.toLowerCase() === this.config.host) { + acct.host = null; + } + try { + const user = await this.remoteUserResolveService.resolveUser(acct.username, acct.host); + + const attributionDomains = user.attributionDomains; + if (attributionDomains.some(x => `.${url?.host.toLowerCase()}`.endsWith(`.${x}`))) { + summary.linkAttribution = { + userId: user.id, + }; + } + } catch { + this.logger.debug('User not found: ' + summary.fediverseCreator); + } + } + + // Adapted from ApiCallService + private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise { + const [user, app] = auth; + + // Authentication + if (user === undefined) { + reply.code(401).send({ + error: { + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + }, + }); + return false; + } + if (user === null) { + reply.code(401).send({ + error: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + }, + }); + return false; + } + + // Authorization + if (user.isSuspended || user.isDeleted) { + reply.code(403).send({ + error: { + message: 'Your account has been suspended.', + code: 'YOUR_ACCOUNT_SUSPENDED', + kind: 'permission', + + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', + }, + }); + return false; + } + if (app && !app.permission.includes('read:account')) { + reply.code(403).send({ + error: { + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + kind: 'permission', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }, + }); + return false; + } + + return true; + } + + private async checkRateLimit(auth: AuthArray, reply: FastifyReply): Promise { + const info = await this.rateLimiterService.limit(previewLimit, auth[2]); + + // Always send headers, even if not blocked + sendRateLimitHeaders(reply, info); + + if (info.blocked) { + reply.code(429).send({ + error: { + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + }, + }); + + return false; + } + + return true; } } + +function getCacheKey(url: string, lang = 'none') { + return `${url}@${lang}@${cacheFormatVersion}`; +} diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js index 1af1dc545b..4f27e2fb30 100644 --- a/packages/backend/src/server/web/boot.embed.js +++ b/packages/backend/src/server/web/boot.embed.js @@ -103,13 +103,18 @@ if (document.readyState === 'loading') { await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } + + const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + + const title = locale?._bootErrors?.title || 'Failed to initialize Sharkey'; + const reload = locale?.reload || 'Reload'; + document.body.innerHTML = ` -
読み込みに失敗しました
+
${title}
Failed to initialize Sharkey
Error Code: ${code}
`; addStyle(` #sharkey_app, diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 54750e26e5..0488161513 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -142,11 +142,6 @@ document.documentElement.classList.add('useSystemFont'); } - const wallpaper = localStorage.getItem('wallpaper'); - if (wallpaper) { - document.documentElement.style.backgroundImage = `url(${wallpaper})`; - } - const customCss = localStorage.getItem('customCss'); if (customCss && customCss.length > 0) { const style = document.createElement('style'); @@ -166,6 +161,22 @@ await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } + const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + + const messages = Object.assign({ + title: 'Failed to initialize Sharkey', + solution: 'The following actions may solve the problem.', + solution1: 'Update your os and browser', + solution2: 'Disable an adblocker', + solution3: 'Clear the browser cache', + solution4: '(Tor Browser) Set dom.webaudio.enabled to true', + otherOption: 'Other options', + otherOption1: 'Clear preferences and cache', + otherOption2: 'Start the simple client', + otherOption3: 'Start the repair tool', + }, locale?._bootErrors || {}); + const reload = locale?.reload || 'Reload'; + let errorsElement = document.getElementById('errors'); if (!errorsElement) { @@ -175,32 +186,32 @@ -

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

+

${messages.title}

-

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

-

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

-

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

-

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

-

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

+

${messages.solution}

+

${messages.solution1}

+

${messages.solution2}

+

${messages.solution3}

+

${messages.solution4}

- Other options / その他のオプション + ${messages.otherOption}

diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css index 6c96241970..8d03ceadf0 100644 --- a/packages/backend/src/server/web/error.css +++ b/packages/backend/src/server/web/error.css @@ -5,112 +5,107 @@ */ * { - font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; + font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; } #sharkey_app, #splash { - display: none !important; + display: none !important; } body, html { - background-color: #222; - color: #dfddcc; - justify-content: center; - margin: auto; - padding: 10px; - text-align: center; + 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; + 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; + background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); + line-height: 50px; } .button-big:hover { - background: rgb(153, 204, 0); + background: rgb(153, 204, 0); } .button-small { - background: #444; - line-height: 40px; + background: #444; + line-height: 40px; } .button-small:hover { - background: #555; + background: #555; } .button-label-big { - color: #222; - font-weight: bold; - font-size: 20px; - padding: 12px; + color: #222; + font-weight: bold; + font-size: 1.2em; + padding: 12px; } .button-label-small { - color: rgb(153, 204, 0); - font-size: 16px; - padding: 12px; + color: rgb(153, 204, 0); + font-size: 16px; + padding: 12px; } a { - color: rgb(134, 179, 0); - text-decoration: none; + color: rgb(134, 179, 0); + text-decoration: none; } p, li { - font-size: 16px; -} - -.dont-worry, -#msg { - font-size: 18px; + font-size: 16px; } .icon-warning { - color: #dec340; - height: 4rem; - padding-top: 2rem; + color: #dec340; + height: 4rem; + padding-top: 2rem; } h1 { - font-size: 32px; + font-size: 1.5em; + margin: 1em; } 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; + 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; +#errorInfo summary { + cursor: pointer; } -summary > * { - display: inline; - white-space: pre-wrap; +#errorInfo summary>* { + display: inline; } @media screen and (max-width: 500px) { - details { - width: 50%; - } + #errorInfo { + width: 50%; + } } diff --git a/packages/backend/src/server/web/error.js b/packages/backend/src/server/web/error.js new file mode 100644 index 0000000000..4c6ae730b3 --- /dev/null +++ b/packages/backend/src/server/web/error.js @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +'use strict'; + +(() => { + document.addEventListener('DOMContentLoaded', () => { + const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + + const messages = Object.assign({ + title: 'Failed to initialize Sharkey', + serverError: 'If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.', + solution: 'The following actions may solve the problem.', + solution1: 'Update your os and browser', + solution2: 'Disable an adblocker', + solution3: 'Clear the browser cache', + solution4: '(Tor Browser) Set dom.webaudio.enabled to true', + otherOption: 'Other options', + otherOption1: 'Clear preferences and cache', + otherOption2: 'Start the simple client', + otherOption3: 'Start the repair tool', + }, locale?._bootErrors || {}); + const reload = locale?.reload || 'Reload'; + + const reloadEls = document.querySelectorAll('[data-i18n-reload]'); + for (const el of reloadEls) { + el.textContent = reload; + } + + const i18nEls = document.querySelectorAll('[data-i18n]'); + for (const el of i18nEls) { + const key = el.dataset.i18n; + if (key && messages[key]) { + el.textContent = messages[key]; + } + } + }); +})(); diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index 8094a0f6de..1c63d77e06 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -31,6 +31,7 @@ html { margin: auto; width: 64px; height: 64px; + border-radius: 10px; pointer-events: none; } diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css index 5e8786cc4e..0911d562bf 100644 --- a/packages/backend/src/server/web/style.embed.css +++ b/packages/backend/src/server/web/style.embed.css @@ -53,6 +53,7 @@ html.embed.noborder #splash { margin: auto; width: 64px; height: 64px; + border-radius: 10px; pointer-events: none; } diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index f57dbbbf4e..d9d750281d 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -45,6 +45,8 @@ html link(rel='stylesheet' href=`/static-assets/fonts/sharkey-icons/style.css?version=${version}`) link(rel='modulepreload' href=`/vite/${entry.file}`) + | !{customHead} + if !config.frontendManifestExists script(type="module" src="/vite/@vite/client") @@ -95,7 +97,7 @@ html img#splashIcon(src= icon || '/static-assets/splash.png') span#splashText block randomMOTD - = randomMOTD + != randomMOTD div#splashSpinner diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug index 39b75abc4c..99b9634bd8 100644 --- a/packages/backend/src/server/web/views/error.pug +++ b/packages/backend/src/server/web/views/error.pug @@ -31,39 +31,45 @@ html style include ../error.css + script + include ../error.js + 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! + h1(data-i18n="title") Failed to initialize Sharkey button.button-big(onclick="location.reload();") - span.button-label-big Refresh + span.button-label-big(data-i18n-reload) Reload - 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. + p(data-i18n="serverError") 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 + b(data-i18n="solution") The following actions may solve the problem. - p Update your os and browser. - p Disable an adblocker. + p(data-i18n="solution1") Update your os and browser + p(data-i18n="solution2") Disable an adblocker + p(data-i18n="solution3") Clear your browser cache + p(data-i18n="solution4") (Tor Browser) Set dom.webaudio.enabled to true - 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 + details(style="color: #86b300;") + summary(data-i18n="otherOption") Other options + a(href="/flush") + button.button-small + span.button-label-small(data-i18n="otherOption1") Clear preferences and cache + br + a(href="/cli") + button.button-small + span.button-label-small(data-i18n="otherOption2") Start the simple client + br + a(href="/bios") + button.button-small + span.button-label-small(data-i18n="otherOption3") Start the repair tool diff --git a/packages/backend/src/server/web/views/info-card.pug b/packages/backend/src/server/web/views/info-card.pug index 4a9d00a596..0a95ea7b17 100644 --- a/packages/backend/src/server/web/views/info-card.pug +++ b/packages/backend/src/server/web/views/info-card.pug @@ -43,7 +43,7 @@ html } body - a#a(href=`https://${host}` target="_blank") + a#a(href=url target="_blank") header#banner(style=`background-image: url(${meta.bannerUrl})`) div#title= meta.name || host div#content diff --git a/packages/backend/src/server/web/views/oauth.pug b/packages/backend/src/server/web/views/oauth.pug index 1470dbfbdf..4195ccc3a3 100644 --- a/packages/backend/src/server/web/views/oauth.pug +++ b/packages/backend/src/server/web/views/oauth.pug @@ -6,4 +6,6 @@ block meta //- XXX: Remove navigation bar in auth page? meta(name='misskey:oauth:transaction-id' content=transactionId) meta(name='misskey:oauth:client-name' content=clientName) + if clientLogo + meta(name='misskey:oauth:client-logo' content=clientLogo) meta(name='misskey:oauth:scope' content=scope) diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 2c6ef731b8..53eb4e6f3e 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -15,9 +15,11 @@ * receiveFollowRequest - フォローリクエストされた * followRequestAccepted - 自分の送ったフォローリクエストが承認された * roleAssigned - ロールが付与された + * chatRoomInvitationReceived - チャットルームに招待された * achievementEarned - 実績を獲得 * exportCompleted - エクスポートが完了 * login - ログイン + * createToken - トークン作成 * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -34,9 +36,11 @@ export const notificationTypes = [ 'receiveFollowRequest', 'followRequestAccepted', 'roleAssigned', + 'chatRoomInvitationReceived', 'achievementEarned', 'exportCompleted', 'login', + 'createToken', 'scheduledNoteFailed', 'scheduledNotePosted', 'app', @@ -132,6 +136,7 @@ export const moderationLogTypes = [ 'deletePage', 'deleteFlash', 'deleteGalleryPost', + 'deleteChatRoom', 'acceptQuotesUser', 'rejectQuotesUser', 'acceptQuotesInstance', @@ -151,6 +156,7 @@ export const moderationLogTypes = [ 'createPromo', 'addRelay', 'removeRelay', + 'updateProxyAccountDescription', ] as const; export type ModerationLogPayloads = { @@ -450,6 +456,14 @@ export type ModerationLogPayloads = { id: string; host: string; }; + deleteChatRoom: { + roomId: string; + room: any; + }; + updateProxyAccountDescription: { + before: string | null; + after: string | null; + }; clearUserFiles: { userId: string; userUsername: string; @@ -518,21 +532,21 @@ export type ModerationLogPayloads = { 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] extends (Record | null) + T[K] extends Date + ? string + : T[K] extends (Date | null) + ? (string | null) + : T[K] extends Record + ? Serialized + : T[K] extends (Record | null) ? (Serialized | null) - : T[K] extends (Record | undefined) + : T[K] extends (Record | undefined) ? (Serialized | undefined) - : T[K]; + : T[K]; }; export type FilterUnionByProperty< - Union, - Property extends string | number | symbol, - Condition + Union, + Property extends string | number | symbol, + Condition, > = Union extends Record ? Union : never; diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml index 8b270e58f7..a7e907c3ee 100644 --- a/packages/backend/test-federation/compose.tpl.yml +++ b/packages/backend/test-federation/compose.tpl.yml @@ -17,7 +17,6 @@ services: - ./.config/docker.env environment: - NODE_ENV=production - - COREPACK_DEFAULT_TO_LATEST=0 volumes: - type: bind source: ../../../built @@ -82,7 +81,7 @@ services: working_dir: /misskey command: > bash -c " - corepack enable && corepack prepare + npm install -g pnpm pnpm -F backend migrate pnpm -F backend start " diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml index a5a7223982..4df4ced365 100644 --- a/packages/backend/test-federation/compose.yml +++ b/packages/backend/test-federation/compose.yml @@ -9,7 +9,7 @@ services: service: misskey command: > bash -c " - corepack enable && corepack prepare + npm install -g pnpm pnpm -F backend i pnpm -F misskey-js i pnpm -F misskey-reversi i @@ -20,12 +20,15 @@ services: depends_on: a.test: condition: service_healthy + misskey.a.test: + condition: service_healthy b.test: condition: service_healthy + misskey.b.test: + condition: service_healthy environment: - NODE_ENV=development - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt - - COREPACK_DEFAULT_TO_LATEST=0 volumes: - type: bind source: ../package.json @@ -74,7 +77,7 @@ services: working_dir: /misskey entrypoint: > bash -c ' - corepack enable && corepack prepare + npm install -g pnpm pnpm -F misskey-js i --frozen-lockfile pnpm -F backend i --frozen-lockfile exec "$0" "$@" @@ -86,8 +89,6 @@ services: depends_on: redis.test: condition: service_healthy - environment: - - COREPACK_DEFAULT_TO_LATEST=0 volumes: - type: bind source: ../package.json @@ -116,7 +117,7 @@ services: working_dir: /misskey command: > bash -c " - corepack enable && corepack prepare + npm install -g pnpm pnpm -F backend i --frozen-lockfile pnpm exec tsc -p ./packages/backend/test-federation node ./packages/backend/test-federation/built/daemon.js diff --git a/packages/backend/test-federation/test/abuse-report.test.ts b/packages/backend/test-federation/test/abuse-report.test.ts index b54d6222b4..ddc8e4f9d0 100644 --- a/packages/backend/test-federation/test/abuse-report.test.ts +++ b/packages/backend/test-federation/test/abuse-report.test.ts @@ -35,7 +35,7 @@ describe('Abuse report', () => { const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {}); const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0]; // NOTE: reporter is not Alice, and is not moderator in A - strictEqual(reportInB.reporter.url, 'https://a.test/@instance.actor'); + strictEqual(reportInB.reporter.url, 'https://a.test/@system.actor'); strictEqual(reportInB.targetUserId, bob.id); // NOTE: cannot forward multiple times diff --git a/packages/backend/test-federation/test/note.test.ts b/packages/backend/test-federation/test/note.test.ts index 220c22e198..1584f9587e 100644 --- a/packages/backend/test-federation/test/note.test.ts +++ b/packages/backend/test-federation/test/note.test.ts @@ -139,29 +139,99 @@ describe('Note', () => { }); describe('Deletion', () => { - describe('Check Delete consistency', () => { - let carol: LoginUser; + describe('Check Delete is delivered', () => { + describe('To followers', () => { + let carol: LoginUser; - beforeAll(async () => { - carol = await createAccount('a.test'); + beforeAll(async () => { + carol = await createAccount('a.test'); - await carol.client.request('following/create', { userId: bobInA.id }); - await sleep(); + await carol.client.request('following/create', { userId: bobInA.id }); + await sleep(); + }); + + test('Check', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, carol); + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await carol.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + + afterAll(async () => { + await carol.client.request('following/delete', { userId: bobInA.id }); + await sleep(); + }); }); - test('Delete is derivered to followers', async () => { - const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; - const noteInA = await resolveRemoteNote('b.test', note.id, carol); - await bob.client.request('notes/delete', { noteId: note.id }); - await sleep(); + describe('To renoted and not followed user', () => { + test('Check', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, alice); + await alice.client.request('notes/create', { renoteId: noteInA.id }); + await sleep(); - await rejects( - async () => await carol.client.request('notes/show', { noteId: noteInA.id }), - (err: any) => { - strictEqual(err.code, 'NO_SUCH_NOTE'); - return true; - }, - ); + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await alice.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + }); + + describe('To replied and not followed user', () => { + test('Check', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, alice); + await alice.client.request('notes/create', { text: 'Hello Bob!', replyId: noteInA.id }); + await sleep(); + + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await alice.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + }); + + /** + * FIXME: not delivered + * @see https://github.com/misskey-dev/misskey/issues/15548 + */ + describe('To only resolved and not followed user', () => { + test.failing('Check', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, alice); + await sleep(); + + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await alice.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); }); }); diff --git a/packages/backend/test-federation/test/timeline.test.ts b/packages/backend/test-federation/test/timeline.test.ts index 2250bf4a42..00635e654b 100644 --- a/packages/backend/test-federation/test/timeline.test.ts +++ b/packages/backend/test-federation/test/timeline.test.ts @@ -24,7 +24,7 @@ describe('Timeline', () => { }); type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag'); - type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag'); + type TimelineEndpoint = keyof Misskey.Endpoints & (`notes/${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag'); const timelineMap = new Map([ ['antenna', 'antennas/notes'], ['globalTimeline', 'notes/global-timeline'], diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts index 76605e61d4..ee69e857bc 100644 --- a/packages/backend/test-federation/test/user.test.ts +++ b/packages/backend/test-federation/test/user.test.ts @@ -37,6 +37,7 @@ describe('User', () => { 'id', 'host', 'avatarUrl', + 'avatarBlurhash', 'instance', 'badgeRoles', 'url', @@ -379,7 +380,9 @@ describe('User', () => { strictEqual(followers.length, 1); // followed by Bob await alice.client.request('i/delete-account', { password: alice.password }); - await sleep(); + // NOTE: user deletion query is slow + // FIXME: ensure user is removed successfully + await sleep(10000); const following = await bob.client.request('users/following', { userId: bob.id }); strictEqual(following.length, 0); // no following relation @@ -477,7 +480,9 @@ describe('User', () => { strictEqual(followers.length, 1); // followed by Bob await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); - await sleep(); + // NOTE: user deletion query is slow + // FIXME: ensure user is removed successfully + await sleep(10000); const following = await bob.client.request('users/following', { userId: bob.id }); strictEqual(following.length, 0); // no following relation diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts index 7b2d023685..b155103efa 100644 --- a/packages/backend/test-federation/test/utils.ts +++ b/packages/backend/test-federation/test/utils.ts @@ -22,7 +22,7 @@ export type LoginUser = SigninResponse & { client: Misskey.api.APIClient; username: string; password: string; -} +}; /** used for avoiding overload and some endpoints */ export type Request = < @@ -36,7 +36,7 @@ export type Request = < type Host = 'a.test' | 'b.test'; -export async function sleep(ms = 200): Promise { +export async function sleep(ms = 250): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/packages/backend/test-federation/tsconfig.json b/packages/backend/test-federation/tsconfig.json index 3a1cb3b9f3..16b333f877 100644 --- a/packages/backend/test-federation/tsconfig.json +++ b/packages/backend/test-federation/tsconfig.json @@ -3,7 +3,7 @@ /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ diff --git a/packages/backend/test-server/.swcrc b/packages/backend/test-server/.swcrc index e3d6935169..eeac7eabc6 100644 --- a/packages/backend/test-server/.swcrc +++ b/packages/backend/test-server/.swcrc @@ -1,5 +1,5 @@ { - "$schema": "https://json.schemastore.org/swcrc", + "$schema": "https://swc.rs/schema.json", "jsc": { "parser": { "syntax": "typescript", diff --git a/packages/backend/test-server/tsconfig.json b/packages/backend/test-server/tsconfig.json index 10313699c2..cb394ecccd 100644 --- a/packages/backend/test-server/tsconfig.json +++ b/packages/backend/test-server/tsconfig.json @@ -23,6 +23,7 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "incremental": true, "rootDir": "../src", "baseUrl": "./", "paths": { diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 881e88d40f..1bbacd065b 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -6,7 +6,6 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { api, failedApiCall, @@ -19,6 +18,7 @@ import { userList, } from '../utils.js'; import type * as misskey from 'misskey-js'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { return selector(a).localeCompare(selector(b)); @@ -146,6 +146,7 @@ describe('アンテナ', () => { caseSensitive: false, createdAt: new Date(response.createdAt).toISOString(), excludeKeywords: [['']], + excludeNotesInSensitiveChannel: false, hasUnreadNote: false, isActive: true, keywords: [['keyword']], @@ -217,6 +218,8 @@ describe('アンテナ', () => { { parameters: () => ({ withReplies: true }) }, { parameters: () => ({ withFile: false }) }, { parameters: () => ({ withFile: true }) }, + { parameters: () => ({ excludeNotesInSensitiveChannel: false }) }, + { parameters: () => ({ excludeNotesInSensitiveChannel: true }) }, ]; test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { const response = await successfulApiCall({ @@ -232,11 +235,11 @@ describe('アンテナ', () => { await failedApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] }, - user: alice + user: alice, }, { status: 400, code: 'EMPTY_KEYWORD', - id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a' + id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a', }); }); //#endregion @@ -271,11 +274,11 @@ describe('アンテナ', () => { await failedApiCall({ endpoint: 'antennas/update', parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] }, - user: alice + user: alice, }, { status: 400, code: 'EMPTY_KEYWORD', - id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4' + id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4', }); }); @@ -372,14 +375,23 @@ describe('アンテナ', () => { ], }, { - // https://github.com/misskey-dev/misskey/issues/9025 - label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。', + label: 'フォロワー限定投稿とDM投稿を含む', parameters: () => ({}), 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] }) }, + { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }), included: true }, + ], + }, + { + label: 'フォロワー限定投稿とDM投稿を含まない', + parameters: () => ({}), + posts: [ + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'public' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'home' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'followers' }) }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [carol.id] }) }, ], }, { @@ -626,6 +638,41 @@ describe('アンテナ', () => { assert.deepStrictEqual(response, expected); }); + test('が取得できること(センシティブチャンネルのノートを除く)', async () => { + const keyword = 'キーワード'; + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]], excludeNotesInSensitiveChannel: true }, + user: alice, + }); + const nonSensitiveChannel = await successfulApiCall({ + endpoint: 'channels/create', + parameters: { name: 'test', isSensitive: false }, + user: alice, + }); + const sensitiveChannel = await successfulApiCall({ + endpoint: 'channels/create', + parameters: { name: 'test', isSensitive: true }, + user: alice, + }); + + const noteInLocal = await post(bob, { text: `test ${keyword}` }); + const noteInNonSensitiveChannel = await post(bob, { text: `test ${keyword}`, channelId: nonSensitiveChannel.id }); + await post(bob, { text: `test ${keyword}`, channelId: sensitiveChannel.id }); + + const response = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + // 最後に投稿したものが先頭に来る。 + const expected = [ + noteInNonSensitiveChannel, + noteInLocal, + ]; + assert.deepStrictEqual(response, expected); + }); + test.skip('が取得でき、日付指定のPaginationに一貫性があること', async () => { }); test.each([ { label: 'ID指定', offsetBy: 'id' }, diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index a130c3698d..7ae1ee4523 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -182,7 +182,6 @@ describe('クリップ', () => { { 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({ @@ -199,6 +198,23 @@ describe('クリップ', () => { id: '3d81ceae-475f-4600-b2a8-2bc116157532', })); + test('の作成はdescriptionが空文字ならnullになる', async () => { + const clip = await successfulApiCall({ + endpoint: 'clips/create', + parameters: { + ...defaultCreate(), + description: '', + }, + user: alice, + }); + + assert.deepStrictEqual(clip, { + ...clip, + ...defaultCreate(), + description: null, + }); + }); + test('の更新ができる', async () => { const res = await update({ clipId: (await create()).id, @@ -249,6 +265,24 @@ describe('クリップ', () => { ...assertion, })); + test('の更新はdescriptionが空文字ならnullになる', async () => { + const clip = await successfulApiCall({ + endpoint: 'clips/update', + parameters: { + clipId: (await create()).id, + name: 'updated', + description: '', + }, + user: alice, + }); + + assert.deepStrictEqual(clip, { + ...clip, + name: 'updated', + description: null, + }); + }); + test('の削除ができる', async () => { await deleteClip({ clipId: (await create()).id, diff --git a/packages/backend/test/e2e/exports.ts b/packages/backend/test/e2e/exports.ts index 4bcecc9716..1a703b3d36 100644 --- a/packages/backend/test/e2e/exports.ts +++ b/packages/backend/test/e2e/exports.ts @@ -16,7 +16,7 @@ describe('export-clips', () => { let bob: misskey.entities.SignupResponse; // XXX: Any better way to get the result? - async function pollFirstDriveFile() { + async function pollFirstDriveFile(): Promise { while (true) { const files = (await api('drive/files', {}, alice)).body; if (!files.length) { diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 0322ac5546..740295bda8 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -6,17 +6,17 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { channel, clip, cookie, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js'; +import { channel, clip, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js'; import type { SimpleGetResponse } from '../utils.js'; import type * as misskey from 'misskey-js'; -// Request Accept +// Request Accept in lowercase const ONLY_AP = 'application/activity+json'; const PREFER_AP = 'application/activity+json, */*'; const PREFER_HTML = 'text/html, */*'; const UNSPECIFIED = '*/*'; -// Response Content-Type +// Response Content-Type in lowercase const AP = 'application/activity+json; charset=utf-8'; const HTML = 'text/html; charset=utf-8'; const JSON_UTF8 = 'application/json; charset=utf-8'; @@ -44,7 +44,8 @@ describe('Webリソース', () => { const { path, accept, cookie, type } = param; const res = await simpleGet(path, accept, cookie); assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, type ?? HTML); + // Header values are case-insensitive + assert.strictEqual(res.type?.toLowerCase(), (type ?? HTML).toLowerCase()); return res; }; @@ -95,8 +96,7 @@ describe('Webリソース', () => { 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-doc', type: HTML }, { path: '/api.json', type: JSON_UTF8 }, { path: '/api-console', type: HTML }, { path: '/_info_card_', type: HTML }, @@ -156,17 +156,17 @@ describe('Webリソース', () => { describe(' has entry such ', () => { beforeEach(() => { - post(alice, { text: "**a**" }); + post(alice, { text: '**a**' }); }); test('MFMを含まない。', async () => { - const content = await simpleGet(path(alice.username), "*/*", undefined, res => res.text()); + const content = await simpleGet(path(alice.username), '*/*', undefined, res => res.text()); const _body: unknown = content.body; // JSONフィードのときは改めて文字列化する - const body: string = typeof (_body) === "object" ? JSON.stringify(_body) : _body as string; + const body: string = typeof (_body) === 'object' ? JSON.stringify(_body) : _body as string; - if (body.includes("**a**")) { - throw new Error("MFM shouldn't be included"); + if (body.includes('**a**')) { + throw new Error('MFM shouldn\'t be included'); } }); }); @@ -180,24 +180,6 @@ describe('Webリソース', () => { })); }); - 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, diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index f37da288b7..b464c24287 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -51,30 +51,8 @@ describe('Mute', () => { assert.strictEqual(res.body.some(note => 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'); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index ef7a6a579d..1dc8d87593 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -19,7 +19,7 @@ import { ResourceOwnerPassword, } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; -import { JSDOM } from 'jsdom'; +import { load as cheerio } from 'cheerio/slim'; import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'; import { api, port, sendEnvUpdateRequest, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; @@ -72,11 +72,12 @@ const clientConfig: ModuleOptions<'client_id'> = { }, }; -function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } { - const fragment = JSDOM.fragment(html); +function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined, clientLogo: string | undefined } { + const fragment = cheerio(html); return { - transactionId: fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content, - clientName: fragment.querySelector('meta[name="misskey:oauth:client-name"]')?.content, + transactionId: fragment('meta[name="misskey:oauth:transaction-id"][content]').attr('content'), + clientName: fragment('meta[name="misskey:oauth:client-name"][content]').attr('content'), + clientLogo: fragment('meta[name="misskey:oauth:client-logo"][content]').attr('content'), }; } @@ -147,7 +148,7 @@ function assertIndirectError(response: Response, error: string): void { async function assertDirectError(response: Response, status: number, error: string): Promise { assert.strictEqual(response.status, status); - const data = await response.json(); + const data = await response.json() as any; assert.strictEqual(data.error, error); } @@ -703,7 +704,7 @@ describe('OAuth', () => { const response = await fetch(new URL('.well-known/oauth-authorization-server', host)); assert.strictEqual(response.status, 200); - const body = await response.json(); + const body = await response.json() as any; assert.strictEqual(body.issuer, 'http://misskey.local'); assert.ok(body.scopes_supported.includes('write:notes')); }); @@ -915,6 +916,59 @@ describe('OAuth', () => { assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); }); + test('With Logo', 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); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient'); + assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); + }); + + test('Missing Logo', 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); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient'); + assert.strictEqual(meta.clientLogo, undefined); + }); + test('Mismatching URL in h-app', async () => { sender = (reply): void => { reply.header('Link', '; rel="redirect_uri"'); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 72f26a38e0..af905b4c91 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -148,7 +148,7 @@ describe('Streaming', () => { test('フォローしているユーザーの投稿が流れる', async () => { const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'foo' }, kyoko), // kyoko posts msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -158,7 +158,7 @@ describe('Streaming', () => { test('フォローしているユーザーの visibility: followers な投稿が流れる', async () => { const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + 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 ); @@ -170,7 +170,7 @@ describe('Streaming', () => { const note = await post(kyoko, { text: 'foo', visibility: 'followers' }); const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.id }, kyoko), // kyoko posts msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo', ); @@ -182,7 +182,7 @@ describe('Streaming', () => { const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' }); const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { text: 'reply to chitose\'s followers-only post', replyId: chitoseNote.id }, kyoko), // kyoko's reply to chitose's followers-only post msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -195,7 +195,7 @@ describe('Streaming', () => { const kyokoReply = await post(kyoko, { text: 'reply to followers-only post', replyId: chitoseNote.id }); const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + ayano, 'homeTimeline', // ayano:home () => api('notes/create', { renoteId: kyokoReply.id }, kyoko), // kyoko's renote of kyoko's reply to chitose's followers-only post msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); @@ -205,7 +205,7 @@ describe('Streaming', () => { test('フォローしていないユーザーの投稿は流れない', async () => { const fired = await waitFire( - kyoko, 'homeTimeline', // kyoko:home + kyoko, 'homeTimeline', // kyoko:home () => api('notes/create', { text: 'foo' }, ayano), // ayano posts msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano ); @@ -215,7 +215,7 @@ describe('Streaming', () => { test('フォローしているユーザーのダイレクト投稿が流れる', async () => { const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + 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 ); @@ -225,7 +225,7 @@ describe('Streaming', () => { test('フォローしているユーザーでも自分が指定されていないダイレクト投稿は流れない', async () => { const fired = await waitFire( - ayano, 'homeTimeline', // ayano:home + 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 ); @@ -574,7 +574,7 @@ describe('Streaming', () => { test('withReplies = falseでフォローしてる人によるリプライが流れてくる', async () => { const fired = await waitFire( - ayano, 'globalTimeline', // ayano:Global + ayano, 'globalTimeline', // ayano:Global () => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko), // kyoko posts msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko ); diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 1ac99df884..1edc178fc2 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -38,48 +38,6 @@ describe('Note thread mute', () => { assert.strictEqual(res.body.some(note => 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' }); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 319c8581f4..d6d2cb33f0 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -397,7 +397,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); - }, 1000 * 15); + }, 1000 * 30); test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 7b21834b57..4b3ec856f1 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -15,7 +15,7 @@ describe('ユーザー', () => { // エンティティとしてのユーザーを主眼においたテストを記述する // (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする) - const stripUndefined = (orig: T): Partial => { + const stripUndefined = (orig: T): Partial => { return Object.entries({ ...orig }) .filter(([, value]) => value !== undefined) .reduce((obj: Partial, [key, value]) => { @@ -86,6 +86,8 @@ describe('ユーザー', () => { publicReactions: user.publicReactions, followingVisibility: user.followingVisibility, followersVisibility: user.followersVisibility, + chatScope: user.chatScope, + canChat: user.canChat, roles: user.roles, memo: user.memo, }); @@ -135,6 +137,7 @@ describe('ユーザー', () => { hasUnreadAnnouncement: user.hasUnreadAnnouncement, hasUnreadAntenna: user.hasUnreadAntenna, hasUnreadChannel: user.hasUnreadChannel, + hasUnreadChatMessages: user.hasUnreadChatMessages, hasUnreadNotification: user.hasUnreadNotification, unreadNotificationsCount: user.unreadNotificationsCount, hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, @@ -309,7 +312,9 @@ describe('ユーザー', () => { assert.strictEqual(response.name, null); assert.strictEqual(response.username, 'zoe'); assert.strictEqual(response.host, null); - response.avatarUrl && assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + if (response.avatarUrl) { + assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + } assert.strictEqual(response.avatarBlurhash, null); assert.deepStrictEqual(response.avatarDecorations, []); assert.strictEqual(response.isBot, false); @@ -350,6 +355,8 @@ describe('ユーザー', () => { assert.strictEqual(response.publicReactions, true); assert.strictEqual(response.followingVisibility, 'public'); assert.strictEqual(response.followersVisibility, 'public'); + assert.strictEqual(response.chatScope, 'mutual'); + assert.strictEqual(response.canChat, true); assert.deepStrictEqual(response.roles, []); assert.strictEqual(response.memo, null); @@ -376,6 +383,7 @@ describe('ユーザー', () => { assert.strictEqual(response.hasUnreadAnnouncement, false); assert.strictEqual(response.hasUnreadAntenna, false); assert.strictEqual(response.hasUnreadChannel, false); + assert.strictEqual(response.hasUnreadChatMessages, false); assert.strictEqual(response.hasUnreadNotification, false); assert.strictEqual(response.unreadNotificationsCount, 0); assert.strictEqual(response.hasPendingReceivedFollowRequest, false); @@ -759,7 +767,7 @@ describe('ユーザー', () => { }); test.each([ { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: () => userNotExplorable }, - { label: 'ミュートユーザーが含まれる', user: () => userMutedByAlice }, + { label: 'ミュートユーザーが含まれない', user: () => userMutedByAlice, excluded: true }, { label: 'ブロックされているユーザーが含まれる', user: () => userBlockedByAlice }, { label: 'ブロックしてきているユーザーが含まれる', user: () => userBlockingAlice }, { label: '承認制ユーザーが含まれる', user: () => userLocking }, diff --git a/packages/backend/test/misc/FakeInternalEventService.ts b/packages/backend/test/misc/FakeInternalEventService.ts new file mode 100644 index 0000000000..d18a080eaf --- /dev/null +++ b/packages/backend/test/misc/FakeInternalEventService.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Listener, ListenerProps } from '@/core/InternalEventService.js'; +import type Redis from 'ioredis'; +import type { GlobalEventService, InternalEventTypes } from '@/core/GlobalEventService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; +import { bindThis } from '@/decorators.js'; + +type FakeCall = [K, Parameters]; +type FakeListener = [K, Listener, ListenerProps]; + +/** + * Minimal implementation of InternalEventService meant for use in unit tests. + * There is no redis connection, and metadata is tracked in the public _calls and _listeners arrays. + * The on/off/emit methods are fully functional and can be called in tests to invoke any registered listeners. + */ +export class FakeInternalEventService extends InternalEventService { + /** + * List of calls to public methods, in chronological order. + */ + public _calls: FakeCall[] = []; + + /** + * List of currently registered listeners. + */ + public _listeners: FakeListener[] = []; + + /** + * Resets the mock. + * Clears all listeners and tracked calls. + */ + public _reset() { + this._calls = []; + this._listeners = []; + } + + /** + * Simulates a remote event sent from another process in the cluster via redis. + */ + @bindThis + public async _emitRedis(type: K, value: InternalEventTypes[K]): Promise { + await this.emit(type, value, false); + } + + constructor() { + super( + { on: () => {} } as unknown as Redis.Redis, + {} as unknown as GlobalEventService, + ); + } + + @bindThis + public on(type: K, listener: Listener, props?: ListenerProps): void { + if (!this._listeners.some(l => l[0] === type && l[1] === listener)) { + this._listeners.push([type, listener as Listener, props ?? {}]); + } + this._calls.push(['on', [type, listener as Listener, props]]); + } + + @bindThis + public off(type: K, listener: Listener): void { + this._listeners = this._listeners.filter(l => l[0] !== type || l[1] !== listener); + this._calls.push(['off', [type, listener as Listener]]); + } + + @bindThis + public async emit(type: K, value: InternalEventTypes[K], isLocal = true): Promise { + for (const listener of this._listeners) { + if (listener[0] === type) { + if ((isLocal && !listener[2].ignoreLocal) || (!isLocal && !listener[2].ignoreRemote)) { + await listener[1](value, type, isLocal); + } + } + } + this._calls.push(['emit', [type, value]]); + } + + @bindThis + public dispose(): void { + this._listeners = []; + this._calls.push(['dispose', []]); + } + + @bindThis + public onApplicationShutdown(): void { + this._calls.push(['onApplicationShutdown', []]); + } +} + diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index f11097b986..34241d13cb 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -7,14 +7,10 @@ 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 { IObject, IObjectWithId } 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 { FollowRequestsRepository, MiMeta, @@ -23,8 +19,13 @@ import type { PollsRepository, UsersRepository, } from '@/models/_.js'; +import type { CacheService } from '@/core/CacheService.js'; import { ApLogService } from '@/core/ApLogService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; +import { fromTuple } from '@/misc/from-tuple.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { bindThis } from '@/decorators.js'; +import { Resolver } from '@/core/activitypub/ApResolverService.js'; type MockResponse = { type: string; @@ -45,7 +46,7 @@ export class MockResolver extends Resolver { {} as NoteReactionsRepository, {} as FollowRequestsRepository, {} as UtilityService, - {} as InstanceActorService, + {} as SystemAccountService, {} as ApRequestService, {} as HttpRequestService, {} as ApRendererService, @@ -53,6 +54,7 @@ export class MockResolver extends Resolver { loggerService, {} as ApLogService, {} as ApUtilityService, + {} as CacheService, ); } @@ -72,8 +74,11 @@ export class MockResolver extends Resolver { return this.#remoteGetTrials; } + public async resolve(value: string | [string]): Promise; + public async resolve(value: string | IObject | [string | IObject]): Promise; @bindThis - public async resolve(value: string | IObject): Promise { + public async resolve(value: string | IObject | [string | IObject]): Promise { + value = fromTuple(value); if (typeof value !== 'string') return value; this.#remoteGetTrials.push(value); diff --git a/packages/backend/test/misc/noOpCaches.ts b/packages/backend/test/misc/noOpCaches.ts new file mode 100644 index 0000000000..f3cc1e2ba2 --- /dev/null +++ b/packages/backend/test/misc/noOpCaches.ts @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Redis from 'ioredis'; +import { Inject } from '@nestjs/common'; +import { FakeInternalEventService } from './FakeInternalEventService.js'; +import type { BlockingsRepository, FollowingsRepository, MiUser, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { MiLocalUser } from '@/models/User.js'; +import { MemoryKVCache, MemorySingleCache, RedisKVCache, RedisSingleCache } from '@/misc/cache.js'; +import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js'; +import { CacheService, FollowStats } from '@/core/CacheService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; + +export function noOpRedis() { + return { + set: () => Promise.resolve(), + get: () => Promise.resolve(null), + del: () => Promise.resolve(), + on: () => {}, + off: () => {}, + } as unknown as Redis.Redis; +} + +export class NoOpCacheService extends CacheService { + public readonly fakeRedis: { + [K in keyof Redis.Redis]: Redis.Redis[K]; + }; + public readonly fakeInternalEventService: FakeInternalEventService; + + constructor( + @Inject(DI.usersRepository) + usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutingsRepository) + mutingsRepository: MutingsRepository, + + @Inject(DI.blockingsRepository) + blockingsRepository: BlockingsRepository, + + @Inject(DI.renoteMutingsRepository) + renoteMutingsRepository: RenoteMutingsRepository, + + @Inject(DI.followingsRepository) + followingsRepository: FollowingsRepository, + + @Inject(UserEntityService) + userEntityService: UserEntityService, + ) { + const fakeRedis = noOpRedis(); + const fakeInternalEventService = new FakeInternalEventService(); + + super( + fakeRedis, + fakeRedis, + usersRepository, + userProfilesRepository, + mutingsRepository, + blockingsRepository, + renoteMutingsRepository, + followingsRepository, + userEntityService, + fakeInternalEventService, + ); + + this.fakeRedis = fakeRedis; + this.fakeInternalEventService = fakeInternalEventService; + + // Override caches + this.userByIdCache = new NoOpMemoryKVCache(); + this.localUserByNativeTokenCache = new NoOpMemoryKVCache(); + this.localUserByIdCache = new NoOpMemoryKVCache(); + this.uriPersonCache = new NoOpMemoryKVCache(); + this.userProfileCache = NoOpQuantumKVCache.copy(this.userProfileCache, fakeInternalEventService); + this.userMutingsCache = NoOpQuantumKVCache.copy(this.userMutingsCache, fakeInternalEventService); + this.userBlockingCache = NoOpQuantumKVCache.copy(this.userBlockingCache, fakeInternalEventService); + this.userBlockedCache = NoOpQuantumKVCache.copy(this.userBlockedCache, fakeInternalEventService); + this.renoteMutingsCache = NoOpQuantumKVCache.copy(this.renoteMutingsCache, fakeInternalEventService); + this.userFollowingsCache = NoOpQuantumKVCache.copy(this.userFollowingsCache, fakeInternalEventService); + this.userFollowersCache = NoOpQuantumKVCache.copy(this.userFollowersCache, fakeInternalEventService); + this.hibernatedUserCache = NoOpQuantumKVCache.copy(this.hibernatedUserCache, fakeInternalEventService); + this.userFollowStatsCache = new NoOpMemoryKVCache(); + this.translationsCache = NoOpRedisKVCache.copy(this.translationsCache, fakeRedis); + } +} + +export class NoOpMemoryKVCache extends MemoryKVCache { + constructor() { + super(-1); + } +} + +export class NoOpMemorySingleCache extends MemorySingleCache { + constructor() { + super(-1); + } +} + +export class NoOpRedisKVCache extends RedisKVCache { + constructor(opts?: { + redis?: Redis.Redis; + fetcher?: RedisKVCache['fetcher']; + toRedisConverter?: RedisKVCache['toRedisConverter']; + fromRedisConverter?: RedisKVCache['fromRedisConverter']; + }) { + super( + opts?.redis ?? noOpRedis(), + 'no-op', + { + lifetime: -1, + memoryCacheLifetime: -1, + fetcher: opts?.fetcher, + toRedisConverter: opts?.toRedisConverter, + fromRedisConverter: opts?.fromRedisConverter, + }, + ); + } + + public static copy(cache: RedisKVCache, redis?: Redis.Redis): NoOpRedisKVCache { + return new NoOpRedisKVCache({ + redis, + fetcher: cache.fetcher, + toRedisConverter: cache.toRedisConverter, + fromRedisConverter: cache.fromRedisConverter, + }); + } +} + +export class NoOpRedisSingleCache extends RedisSingleCache { + constructor(opts?: { + redis?: Redis.Redis; + fetcher?: RedisSingleCache['fetcher']; + toRedisConverter?: RedisSingleCache['toRedisConverter']; + fromRedisConverter?: RedisSingleCache['fromRedisConverter']; + }) { + super( + opts?.redis ?? noOpRedis(), + 'no-op', + { + lifetime: -1, + memoryCacheLifetime: -1, + fetcher: opts?.fetcher, + toRedisConverter: opts?.toRedisConverter, + fromRedisConverter: opts?.fromRedisConverter, + }, + ); + } + + public static copy(cache: RedisSingleCache, redis?: Redis.Redis): NoOpRedisSingleCache { + return new NoOpRedisSingleCache({ + redis, + fetcher: cache.fetcher, + toRedisConverter: cache.toRedisConverter, + fromRedisConverter: cache.fromRedisConverter, + }); + } +} + +export class NoOpQuantumKVCache extends QuantumKVCache { + constructor(opts: Omit, 'lifetime'> & { + internalEventService?: InternalEventService, + }) { + super( + opts.internalEventService ?? new FakeInternalEventService(), + 'no-op', + { + ...opts, + lifetime: -1, + }, + ); + } + + public static copy(cache: QuantumKVCache, internalEventService?: InternalEventService): NoOpQuantumKVCache { + return new NoOpQuantumKVCache({ + internalEventService, + fetcher: cache.fetcher, + bulkFetcher: cache.bulkFetcher, + onChanged: cache.onChanged, + }); + } +} diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index 2b562acda8..f3b6a5108d 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -23,6 +23,7 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "incremental": true, "baseUrl": "./", "paths": { "@/*": ["../src/*"] diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts index 1326003c5e..a67cb3664a 100644 --- a/packages/backend/test/unit/AbuseReportNotificationService.ts +++ b/packages/backend/test/unit/AbuseReportNotificationService.ts @@ -11,6 +11,7 @@ import { AbuseReportNotificationRecipientRepository, MiAbuseReportNotificationRecipient, MiAbuseUserReport, + MiMeta, MiSystemWebhook, MiUser, SystemWebhooksRepository, @@ -56,6 +57,15 @@ describe('AbuseReportNotificationService', () => { // -------------------------------------------------------------------------------------- + const meta = {} as MiMeta; + + function updateMeta(newMeta: Partial): void { + for (const key in meta) { + delete (meta as any)[key]; + } + Object.assign(meta, newMeta); + } + async function createUser(data: Partial = {}) { const user = await usersRepository .insert({ @@ -66,6 +76,8 @@ describe('AbuseReportNotificationService', () => { await userProfilesRepository.insert({ userId: user.id, + email: user.username + '@example.com', + emailVerified: true, }); return user; @@ -130,6 +142,9 @@ describe('AbuseReportNotificationService', () => { { provide: GlobalEventService, useFactory: () => ({ publishAdminStream: jest.fn() }), }, + { + provide: DI.meta, useFactory: () => meta, + }, ], }) .compile(); @@ -149,13 +164,15 @@ describe('AbuseReportNotificationService', () => { }); beforeEach(async () => { - root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); - alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); - bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false }); + root = await createUser({ username: 'root', usernameLower: 'root' }); + alice = await createUser({ username: 'alice', usernameLower: 'alice' }); + bob = await createUser({ username: 'bob', usernameLower: 'bob' }); systemWebhook1 = await createWebhook(); systemWebhook2 = await createWebhook(); roleService.getModeratorIds.mockResolvedValue([root.id, alice.id, bob.id]); + + updateMeta({} as MiMeta); }); afterEach(async () => { @@ -367,8 +384,10 @@ describe('AbuseReportNotificationService', () => { id: idService.gen(), targetUserId: alice.id, targetUser: alice, + targetUserInstance: null, reporterId: bob.id, reporter: bob, + reporterInstance: null, assigneeId: null, assignee: null, resolved: false, @@ -390,4 +409,59 @@ describe('AbuseReportNotificationService', () => { expect(webhookService.enqueueSystemWebhook.mock.calls[0][2]).toEqual({ excludes: [systemWebhook2.id] }); }); }); + + describe('collection of recipient-mails', () => { + async function create() { + const recipient = await createRecipient({ + method: 'email', + userId: alice.id, + }); + + return recipient; + } + + test('with nothing set', async () => { + const mails = await service.getRecipientEMailAddresses(); + expect(mails).toEqual([]); + }); + + test('with maintainer mail set', async () => { + updateMeta({ maintainerEmail: 'maintainer_mail' }); + const mails = await service.getRecipientEMailAddresses(); + expect(mails).toEqual(['maintainer_mail']); + }); + + test('with smtp mail set', async () => { + updateMeta({ email: 'smtp_mail' }); + const mails = await service.getRecipientEMailAddresses(); + expect(mails).toEqual(['smtp_mail']); + }); + + test('with maintainer mail and smtp mail set', async () => { + updateMeta({ email: 'smtp_mail', maintainerEmail: 'maintainer_mail' }); + const mails = await service.getRecipientEMailAddresses(); + expect(mails).toEqual(['smtp_mail', 'maintainer_mail']); + }); + + test('with recipients', async () => { + await create(); + + const mails = await service.getRecipientEMailAddresses(); + expect(mails).toEqual([ + 'alice@example.com', + ]); + }); + + test('with recipients and maintainer mail set and smtp mail set', async () => { + await create(); + updateMeta({ maintainerEmail: 'maintainer_mail', email: 'smtp_mail' }); + + const mails = await service.getRecipientEMailAddresses(); + expect(mails).toEqual([ + 'alice@example.com', + 'smtp_mail', + 'maintainer_mail', + ]); + }); + }); }); diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 81da0fac31..32d7df05bf 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -8,9 +8,12 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; +import { NoOpCacheService } from '../misc/noOpCaches.js'; +import { FakeInternalEventService } from '../misc/FakeInternalEventService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository, @@ -44,7 +47,7 @@ describe('AnnouncementService', () => { return usersRepository.insert({ id: genAidx(Date.now()), username: un, - usernameLower: un, + usernameLower: un.toLowerCase(), ...data, }) .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); @@ -71,24 +74,27 @@ describe('AnnouncementService', () => { AnnouncementEntityService, CacheService, IdService, + InternalEventService, + GlobalEventService, + ModerationLogService, ], }) .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') { + if (typeof token === 'function') { const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } }) + .overrideProvider(GlobalEventService).useValue({ + publishMainStream: jest.fn(), + publishBroadcastStream: jest.fn(), + } as unknown as GlobalEventService) + .overrideProvider(ModerationLogService).useValue({ + log: jest.fn(), + }) + .overrideProvider(InternalEventService).useClass(FakeInternalEventService) + .overrideProvider(CacheService).useClass(NoOpCacheService) .compile(); app.enableShutdownHooks(); diff --git a/packages/backend/test/unit/FlashService.ts b/packages/backend/test/unit/FlashService.ts index 12ffaf3421..f2d9832f50 100644 --- a/packages/backend/test/unit/FlashService.ts +++ b/packages/backend/test/unit/FlashService.ts @@ -79,9 +79,9 @@ describe('FlashService', () => { userProfilesRepository = app.get(DI.userProfilesRepository); idService = app.get(IdService); - root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); - alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); - bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false }); + root = await createUser({ username: 'root', usernameLower: 'root' }); + alice = await createUser({ username: 'alice', usernameLower: 'alice' }); + bob = await createUser({ username: 'bob', usernameLower: 'bob' }); }); afterEach(async () => { diff --git a/packages/backend/test/unit/MetaService.ts b/packages/backend/test/unit/MetaService.ts index 19c98eab3d..056838e180 100644 --- a/packages/backend/test/unit/MetaService.ts +++ b/packages/backend/test/unit/MetaService.ts @@ -11,6 +11,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { CoreModule } from '@/core/CoreModule.js'; +import { MetasRepository } from '@/models/_.js'; import type { TestingModule } from '@nestjs/testing'; import type { DataSource } from 'typeorm'; @@ -39,8 +40,8 @@ describe('MetaService', () => { }); test('fetch (cache)', async () => { - const db = app.get(DI.db); - const spy = jest.spyOn(db, 'transaction'); + const metasRepository = app.get(DI.metasRepository); + const spy = jest.spyOn(metasRepository, 'createQueryBuilder'); const result = await metaService.fetch(); @@ -49,12 +50,12 @@ describe('MetaService', () => { }); test('fetch (force)', async () => { - const db = app.get(DI.db); - const spy = jest.spyOn(db, 'transaction'); + const metasRepository = app.get(DI.metasRepository); + const spy = jest.spyOn(metasRepository, 'createQueryBuilder'); const result = await metaService.fetch(true); expect(result.id).toBe('x'); - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalled(); }); }); diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts index e54c006a4f..af1fc4e132 100644 --- a/packages/backend/test/unit/MfmService.ts +++ b/packages/backend/test/unit/MfmService.ts @@ -4,7 +4,7 @@ */ import * as assert from 'assert'; -import * as mfm from '@transfem-org/sfm-js'; +import * as mfm from 'mfm-js'; import { Test } from '@nestjs/testing'; import { CoreModule } from '@/core/CoreModule.js'; @@ -86,7 +86,7 @@ describe('MfmService', () => { test('ruby', async () => { const input = '$[ruby $[group *some* text] ignore me]'; - const output = '

*some* text(ignore me)

'; + const output = '

*some* text(ignore me)

'; assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output); }); }); diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index f4ecfef34d..63e3795a84 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -57,10 +57,13 @@ describe('NoteCreateService', () => { channelId: null, channel: null, userHost: null, + userInstance: null, replyUserId: null, replyUserHost: null, + replyUserInstance: null, renoteUserId: null, renoteUserHost: null, + renoteUserInstance: null, processErrors: [], }; diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 9676abf07b..074430dd31 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -6,19 +6,18 @@ 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 { ModuleMocker } from 'jest-mock'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { RelayService } from '@/core/RelayService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { UtilityService } from '@/core/UtilityService.js'; const moduleMocker = new ModuleMocker(global); @@ -26,8 +25,6 @@ describe('RelayService', () => { let app: TestingModule; let relayService: RelayService; let queueService: jest.Mocked; - let relaysRepository: RelaysRepository; - let userEntityService: UserEntityService; beforeAll(async () => { app = await Test.createTestingModule({ @@ -36,10 +33,11 @@ describe('RelayService', () => { ], providers: [ IdService, - CreateSystemUserService, ApRendererService, RelayService, UserEntityService, + SystemAccountService, + UtilityService, ], }) .useMocker((token) => { @@ -58,8 +56,6 @@ describe('RelayService', () => { relayService = app.get(RelayService); queueService = app.get(QueueService) as jest.Mocked; - relaysRepository = app.get(DI.relaysRepository); - userEntityService = app.get(UserEntityService); }); afterAll(async () => { diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 9c1b1008d6..2afe22618d 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -10,11 +10,15 @@ import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; +import { NoOpCacheService } from '../misc/noOpCaches.js'; +import { FakeInternalEventService } from '../misc/FakeInternalEventService.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import { + InstancesRepository, + MetasRepository, MiMeta, MiRole, MiRoleAssignment, @@ -33,20 +37,36 @@ import { secureRndstr } from '@/misc/secure-rndstr.js'; import { NotificationService } from '@/core/NotificationService.js'; import { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; const moduleMocker = new ModuleMocker(global); describe('RoleService', () => { let app: TestingModule; let roleService: RoleService; + let instancesRepository: InstancesRepository; let usersRepository: UsersRepository; let rolesRepository: RolesRepository; let roleAssignmentsRepository: RoleAssignmentsRepository; let meta: jest.Mocked; + let metasRepository: MetasRepository; let notificationService: jest.Mocked; let clock: lolex.InstalledClock; async function createUser(data: Partial = {}) { + if (data.host != null) { + await instancesRepository + .createQueryBuilder('instance') + .insert() + .values({ + id: genAidx(Date.now()), + firstRetrievedAt: new Date(), + host: data.host, + }) + .orIgnore() + .execute(); + } + const un = secureRndstr(16); const x = await usersRepository.insert({ id: genAidx(Date.now()), @@ -57,6 +77,12 @@ describe('RoleService', () => { return await usersRepository.findOneByOrFail(x.identifiers[0]); } + async function createRoot(data: Partial = {}) { + const user = await createUser(data); + meta.rootUserId = user.id; + return user; + } + async function createRole(data: Partial = {}) { const x = await rolesRepository.insert({ id: genAidx(Date.now()), @@ -122,26 +148,30 @@ describe('RoleService', () => { provide: NotificationService.name, useExisting: NotificationService, }, + MetaService, + InternalEventService, ], }) .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(); } }) + .overrideProvider(MetaService).useValue({ fetch: jest.fn() }) + .overrideProvider(InternalEventService).useClass(FakeInternalEventService) + .overrideProvider(CacheService).useClass(NoOpCacheService) .compile(); app.enableShutdownHooks(); roleService = app.get(RoleService); + instancesRepository = app.get(DI.instancesRepository); usersRepository = app.get(DI.usersRepository); rolesRepository = app.get(DI.rolesRepository); roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository); + metasRepository = app.get(DI.metasRepository); meta = app.get(DI.meta) as jest.Mocked; notificationService = app.get(NotificationService) as jest.Mocked; @@ -153,7 +183,7 @@ describe('RoleService', () => { clock.uninstall(); await Promise.all([ - app.get(DI.metasRepository).delete({}), + metasRepository.delete({}), usersRepository.delete({}), rolesRepository.delete({}), roleAssignmentsRepository.delete({}), @@ -279,7 +309,7 @@ describe('RoleService', () => { describe('getModeratorIds', () => { test('includeAdmins = false, includeRoot = false, excludeExpire = false', async () => { const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -305,7 +335,7 @@ describe('RoleService', () => { test('includeAdmins = false, includeRoot = false, excludeExpire = true', async () => { const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -331,7 +361,7 @@ describe('RoleService', () => { test('includeAdmins = true, includeRoot = false, excludeExpire = false', async () => { const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -357,7 +387,7 @@ describe('RoleService', () => { test('includeAdmins = true, includeRoot = false, excludeExpire = true', async () => { const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -383,7 +413,7 @@ describe('RoleService', () => { test('includeAdmins = false, includeRoot = true, excludeExpire = false', async () => { const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -409,7 +439,7 @@ describe('RoleService', () => { test('root has moderator role', async () => { const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createUser({ isRoot: true }), + createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -433,7 +463,7 @@ describe('RoleService', () => { test('root has administrator role', async () => { const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createUser({ isRoot: true }), + createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -457,7 +487,7 @@ describe('RoleService', () => { test('root has moderator role(expire)', async () => { const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createUser({ isRoot: true }), + createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts index efed905e02..5bc86f6616 100644 --- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -54,7 +54,7 @@ class DummyFastifyReply { } class DummyFastifyRequest { public ip: string; - public body: {credential: any, context: string}; + public body: { credential: any, context: string }; public headers: IncomingHttpHeaders = { 'accept': 'application/json' }; constructor(body?: any) { this.ip = '0.0.0.0'; @@ -123,8 +123,8 @@ describe('SigninWithPasskeyApiService', () => { jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify); const dummyUser = { - id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null, - }; + id: uid, username: uid, usernameLower: uid.toLowerCase(), uri: null, host: null, + }; const dummyProfile = { userId: uid, password: 'qwerty', diff --git a/packages/backend/test/unit/SystemWebhookService.ts b/packages/backend/test/unit/SystemWebhookService.ts index fee4acb305..61187e9f2a 100644 --- a/packages/backend/test/unit/SystemWebhookService.ts +++ b/packages/backend/test/unit/SystemWebhookService.ts @@ -97,7 +97,7 @@ describe('SystemWebhookService', () => { } async function beforeEachImpl() { - root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' }); + root = await createUser({ username: 'root', usernameLower: 'root' }); } async function afterEachImpl() { diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts index 7ea325d420..a6b331d1cb 100644 --- a/packages/backend/test/unit/UserSearchService.ts +++ b/packages/backend/test/unit/UserSearchService.ts @@ -7,16 +7,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { describe, jest, test } from '@jest/globals'; import { In } from 'typeorm'; import { UserSearchService } from '@/core/UserSearchService.js'; -import { FollowingsRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { FollowingsRepository, InstancesRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { genAidx } from '@/misc/id/aidx.js'; describe('UserSearchService', () => { let app: TestingModule; let service: UserSearchService; + let instancesRepository: InstancesRepository; let usersRepository: UsersRepository; let followingsRepository: FollowingsRepository; let idService: IdService; @@ -35,6 +37,19 @@ describe('UserSearchService', () => { let bobby: MiUser; async function createUser(data: Partial = {}) { + if (data.host != null) { + await instancesRepository + .createQueryBuilder('instance') + .insert() + .values({ + id: genAidx(Date.now()), + firstRetrievedAt: new Date(), + host: data.host, + }) + .orIgnore() + .execute(); + } + const user = await usersRepository .insert({ id: idService.gen(), @@ -104,6 +119,7 @@ describe('UserSearchService', () => { await app.init(); + instancesRepository = app.get(DI.instancesRepository); usersRepository = app.get(DI.usersRepository); userProfilesRepository = app.get(DI.userProfilesRepository); followingsRepository = app.get(DI.followingsRepository); @@ -113,7 +129,7 @@ describe('UserSearchService', () => { }); beforeEach(async () => { - root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + root = await createUser({ username: 'root', usernameLower: 'root' }); alice = await createUser({ username: 'Alice', usernameLower: 'alice' }); alyce = await createUser({ username: 'Alyce', usernameLower: 'alyce' }); alycia = await createUser({ username: 'Alycia', usernameLower: 'alycia' }); @@ -134,13 +150,13 @@ describe('UserSearchService', () => { await app.close(); }); - describe('search', () => { + describe('searchByUsernameAndHost', () => { test('フォロー中のアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setActive([alice, alyce, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alycia, alysha, alyson]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -154,7 +170,7 @@ describe('UserSearchService', () => { await createFollowings(root, [alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -168,7 +184,7 @@ describe('UserSearchService', () => { await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -181,7 +197,7 @@ describe('UserSearchService', () => { test('フォローしていない非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -195,7 +211,7 @@ describe('UserSearchService', () => { await setActive([root, alyssa, bob, bobbi, alyce, alycia]); await setInactive([alyson, alice, alysha, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { }, { limit: 100 }, root, @@ -216,7 +232,7 @@ describe('UserSearchService', () => { await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, ); @@ -228,7 +244,7 @@ describe('UserSearchService', () => { test('[非ログイン] 非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, ); @@ -240,7 +256,7 @@ describe('UserSearchService', () => { await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al', host: 'exam' }, { limit: 100 }, root, @@ -253,7 +269,7 @@ describe('UserSearchService', () => { await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setSuspended([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, diff --git a/packages/backend/test/unit/UserWebhookService.ts b/packages/backend/test/unit/UserWebhookService.ts index db8f96df28..a2a85e9489 100644 --- a/packages/backend/test/unit/UserWebhookService.ts +++ b/packages/backend/test/unit/UserWebhookService.ts @@ -91,7 +91,7 @@ describe('UserWebhookService', () => { } async function beforeEachImpl() { - root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' }); + root = await createUser({ username: 'root', usernameLower: 'root' }); } async function afterEachImpl() { diff --git a/packages/backend/test/unit/UtilityService.ts b/packages/backend/test/unit/UtilityService.ts index d86e794f2f..cb010ff1f9 100644 --- a/packages/backend/test/unit/UtilityService.ts +++ b/packages/backend/test/unit/UtilityService.ts @@ -22,6 +22,12 @@ describe('UtilityService', () => { test('japanese', () => { assert.equal(utilityService.punyHost('http://www.新聞.com'), 'www.xn--efvv70d.com'); }); + test('simple, with port', () => { + assert.equal(utilityService.punyHost('http://www.foo.com:3000'), 'www.foo.com:3000'); + }); + test('japanese, with port', () => { + assert.equal(utilityService.punyHost('http://www.新聞.com:3000'), 'www.xn--efvv70d.com:3000'); + }); }); describe('punyHostPSLDomain', () => { @@ -31,6 +37,12 @@ describe('UtilityService', () => { test('japanese', () => { assert.equal(utilityService.punyHostPSLDomain('http://www.新聞.com'), 'xn--efvv70d.com'); }); + test('simple, with port', () => { + assert.equal(utilityService.punyHostPSLDomain('http://www.foo.com:3000'), 'foo.com:3000'); + }); + test('japanese, with port', () => { + assert.equal(utilityService.punyHostPSLDomain('http://www.新聞.com:3000'), 'xn--efvv70d.com:3000'); + }); test('lower', () => { assert.equal(utilityService.punyHostPSLDomain('http://foo.github.io'), 'foo.github.io'); assert.equal(utilityService.punyHostPSLDomain('http://foo.bar.github.io'), 'bar.github.io'); @@ -40,4 +52,13 @@ describe('UtilityService', () => { assert.equal(utilityService.punyHostPSLDomain('http://foo.bar.masto.host'), 'bar.masto.host'); }); }); + + describe('toPuny', () => { + test('without port ', () => { + assert.equal(utilityService.toPuny('www.foo.com'), 'www.foo.com'); + }); + test('with port ', () => { + assert.equal(utilityService.toPuny('www.foo.com:3000'), 'www.foo.com:3000'); + }); + }); }); diff --git a/packages/backend/test/unit/WebhookTestService.ts b/packages/backend/test/unit/WebhookTestService.ts index be84ae9b84..736aac40b4 100644 --- a/packages/backend/test/unit/WebhookTestService.ts +++ b/packages/backend/test/unit/WebhookTestService.ts @@ -14,6 +14,7 @@ import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersReposi import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; describe('WebhookTestService', () => { let app: TestingModule; @@ -56,6 +57,11 @@ describe('WebhookTestService', () => { providers: [ WebhookTestService, IdService, + { + provide: CustomEmojiService, useFactory: () => ({ + populateEmojis: jest.fn(), + }), + }, { provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn(), @@ -88,8 +94,8 @@ describe('WebhookTestService', () => { }); beforeEach(async () => { - root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); - alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); + root = await createUser({ username: 'root', usernameLower: 'root' }); + alice = await createUser({ username: 'alice', usernameLower: 'alice' }); userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([ { id: 'dummy-webhook', active: true, userId: alice.id } as MiWebhook, diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 553467499b..ff93e1be07 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -2,15 +2,19 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -import { IdService } from '@/core/IdService.js'; - process.env.NODE_ENV = 'test'; import * as assert from 'assert'; +import { generateKeyPair } from 'crypto'; import { Test } from '@nestjs/testing'; import { jest } from '@jest/globals'; +import { NoOpCacheService } from '../misc/noOpCaches.js'; +import { FakeInternalEventService } from '../misc/FakeInternalEventService.js'; +import type { Config } from '@/config.js'; +import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApImageService } from '@/core/activitypub/models/ApImageService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; @@ -22,13 +26,15 @@ import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; -import { MiMeta, MiNote, MiUser, UserProfilesRepository, UserPublickeysRepository } from '@/models/_.js'; +import { MiMeta, MiNote, MiUser, MiUserKeypair, UserProfilesRepository, UserPublickeysRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DownloadService } from '@/core/DownloadService.js'; -import type { MiRemoteUser } from '@/models/User.js'; import { genAidx } from '@/misc/id/aidx.js'; +import { IdService } from '@/core/IdService.js'; import { MockResolver } from '../misc/mock-resolver.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; +import { MemoryKVCache } from '@/misc/cache.js'; const host = 'https://host1.test'; @@ -97,8 +103,29 @@ describe('ActivityPub', () => { let resolver: MockResolver; let idService: IdService; let userPublickeysRepository: UserPublickeysRepository; + let userKeypairService: UserKeypairService; + let config: Config; const metaInitial = { + id: 'x', + name: 'Test Instance', + shortName: 'Test Instance', + description: 'Test Instance', + langs: [] as string[], + pinnedUsers: [] as string[], + hiddenTags: [] as string[], + prohibitedWordsForNameOfUser: [] as string[], + silencedHosts: [] as string[], + mediaSilencedHosts: [] as string[], + policies: {}, + serverRules: [] as string[], + bannedEmailDomains: [] as string[], + preservedUsernames: [] as string[], + bubbleInstances: [] as string[], + trustedLinkUrlPatterns: [] as string[], + federation: 'all', + federationHosts: [] as string[], + allowUnsignedFetch: 'always', cacheRemoteFiles: true, cacheRemoteSensitiveFiles: true, enableFanoutTimeline: true, @@ -131,6 +158,8 @@ describe('ActivityPub', () => { }, }) .overrideProvider(DI.meta).useFactory({ factory: () => meta }) + .overrideProvider(CacheService).useClass(NoOpCacheService) + .overrideProvider(InternalEventService).useClass(FakeInternalEventService) .compile(); await app.init(); @@ -146,6 +175,8 @@ describe('ActivityPub', () => { resolver = new MockResolver(await app.resolve(LoggerService)); idService = app.get(IdService); userPublickeysRepository = app.get(DI.userPublickeysRepository); + userKeypairService = app.get(UserKeypairService); + config = app.get(DI.config); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error const federatedInstanceService = app.get(FederatedInstanceService); @@ -448,8 +479,6 @@ describe('ActivityPub', () => { describe('JSON-LD', () => { test('Compaction', async () => { - const jsonLd = jsonLdService.use(); - const object = { '@context': [ 'https://www.w3.org/ns/activitystreams', @@ -468,7 +497,7 @@ describe('ActivityPub', () => { unknown: 'test test bar', undefined: 'test test baz', }; - const compacted = await jsonLd.compact(object); + const compacted = await jsonLdService.compact(object); assert.deepStrictEqual(compacted, { '@context': CONTEXT, @@ -486,15 +515,57 @@ describe('ActivityPub', () => { describe(ApRendererService, () => { let note: MiNote; - let author: MiUser; + let author: MiLocalUser; + let keypair: MiUserKeypair; - beforeEach(() => { + beforeEach(async () => { author = new MiUser({ id: idService.gen(), + host: null, + uri: null, + username: 'testAuthor', + usernameLower: 'testauthor', + name: 'Test Author', + isCat: true, + requireSigninToViewContents: true, + makeNotesFollowersOnlyBefore: new Date(2025, 2, 20).valueOf(), + makeNotesHiddenBefore: new Date(2025, 2, 21).valueOf(), + isLocked: true, + isExplorable: true, + hideOnlineStatus: true, + noindex: true, + enableRss: true, + + }) as MiLocalUser; + + const [publicKey, privateKey] = await new Promise<[string, string]>((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]), + )); + keypair = new MiUserKeypair({ + userId: author.id, + user: author, + publicKey, + privateKey, }); + (userKeypairService as unknown as { cache: MemoryKVCache }).cache.set(author.id, keypair); + note = new MiNote({ id: idService.gen(), userId: author.id, + user: author, visibility: 'public', localOnly: false, text: 'Note text', @@ -567,57 +638,171 @@ describe('ActivityPub', () => { expect(result.summary).toBe('original and mandatory'); }); }); + + describe('replies', () => { + it('should be included when visibility=public', async () => { + note.visibility = 'public'; + + const rendered = await rendererService.renderNote(note, author, false); + + expect(rendered.replies).toBeDefined(); + }); + + it('should be included when visibility=home', async () => { + note.visibility = 'home'; + + const rendered = await rendererService.renderNote(note, author, false); + + expect(rendered.replies).toBeDefined(); + }); + + it('should be excluded when visibility=followers', async () => { + note.visibility = 'followers'; + + const rendered = await rendererService.renderNote(note, author, false); + + expect(rendered.replies).not.toBeDefined(); + }); + + it('should be excluded when visibility=specified', async () => { + note.visibility = 'specified'; + + const rendered = await rendererService.renderNote(note, author, false); + + expect(rendered.replies).not.toBeDefined(); + }); + }); }); - describe('renderUpnote', () => { - describe('summary', () => { - // I actually don't know why it does this, but the logic was already there so I've preserved it. - it('should be zero-width space when CW is empty string', async () => { - note.cw = ''; + describe('renderPersonRedacted', () => { + it('should include minimal properties', async () => { + const result = await rendererService.renderPersonRedacted(author); - const result = await rendererService.renderUpNote(note, author, false); + expect(result.type).toBe('Person'); + expect(result.id).toBeTruthy(); + expect(result.inbox).toBeTruthy(); + expect(result.sharedInbox).toBeTruthy(); + expect(result.endpoints.sharedInbox).toBeTruthy(); + expect(result.url).toBeTruthy(); + expect(result.preferredUsername).toBe(author.username); + expect(result.publicKey.owner).toBe(result.id); + expect(result._misskey_requireSigninToViewContents).toBe(author.requireSigninToViewContents); + expect(result._misskey_makeNotesFollowersOnlyBefore).toBe(author.makeNotesFollowersOnlyBefore); + expect(result._misskey_makeNotesHiddenBefore).toBe(author.makeNotesHiddenBefore); + expect(result.discoverable).toBe(author.isExplorable); + expect(result.hideOnlineStatus).toBe(author.hideOnlineStatus); + expect(result.noindex).toBe(author.noindex); + expect(result.indexable).toBe(!author.noindex); + expect(result.enableRss).toBe(author.enableRss); + }); - expect(result.summary).toBe(String.fromCharCode(0x200B)); + it('should not include sensitive properties', async () => { + const result = await rendererService.renderPersonRedacted(author) as IActor; + + expect(result.name).toBeUndefined(); + }); + }); + + describe('renderRepliesCollection', () => { + it('should include type', async () => { + const collection = await rendererService.renderRepliesCollection(note.id); + + expect(collection.type).toBe('OrderedCollection'); + }); + + it('should include id', async () => { + const collection = await rendererService.renderRepliesCollection(note.id); + + expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies`); + }); + + it('should include first', async () => { + const collection = await rendererService.renderRepliesCollection(note.id); + + expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`); + }); + + it('should include totalItems', async () => { + const collection = await rendererService.renderRepliesCollection(note.id); + + expect(collection.totalItems).toBe(0); + }); + }); + + describe('renderRepliesCollectionPage', () => { + describe('with untilId', () => { + it('should include type', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); + + expect(collection.type).toBe('OrderedCollectionPage'); }); - it('should be undefined when CW is null', async () => { - const result = await rendererService.renderUpNote(note, author, false); + it('should include id', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); - expect(result.summary).toBeUndefined(); + expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies?page=true&until_id=abc123`); }); - it('should be CW when present without mandatoryCW', async () => { - note.cw = 'original'; + it('should include partOf', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); - const result = await rendererService.renderUpNote(note, author, false); - - expect(result.summary).toBe('original'); + expect(collection.partOf).toBe(`${config.url}/notes/${note.id}/replies`); }); - it('should be mandatoryCW when present without CW', async () => { - author.mandatoryCW = 'mandatory'; + it('should include first', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); - const result = await rendererService.renderUpNote(note, author, false); - - expect(result.summary).toBe('mandatory'); + expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`); }); - it('should be merged when CW and mandatoryCW are both present', async () => { - note.cw = 'original'; - author.mandatoryCW = 'mandatory'; + it('should include totalItems', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); - const result = await rendererService.renderUpNote(note, author, false); - - expect(result.summary).toBe('original, mandatory'); + expect(collection.totalItems).toBe(0); }); - it('should be CW when CW includes mandatoryCW', async () => { - note.cw = 'original and mandatory'; - author.mandatoryCW = 'mandatory'; + it('should include orderedItems', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); - const result = await rendererService.renderUpNote(note, author, false); + expect(collection.orderedItems).toBeDefined(); + }); + }); - expect(result.summary).toBe('original and mandatory'); + describe('without untilId', () => { + it('should include type', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.type).toBe('OrderedCollectionPage'); + }); + + it('should include id', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies?page=true`); + }); + + it('should include partOf', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.partOf).toBe(`${config.url}/notes/${note.id}/replies`); + }); + + it('should include first', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`); + }); + + it('should include totalItems', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.totalItems).toBe(0); + }); + + it('should include orderedItems', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.orderedItems).toBeDefined(); }); }); }); diff --git a/packages/backend/test/unit/ap-request.ts b/packages/backend/test/unit/ap-request.ts index d3d39240dc..11364e1735 100644 --- a/packages/backend/test/unit/ap-request.ts +++ b/packages/backend/test/unit/ap-request.ts @@ -8,6 +8,7 @@ import httpSignature from '@peertube/http-signature'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; +import { IObject } from '@/core/activitypub/type.js'; export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { return { @@ -24,6 +25,10 @@ export const buildParsedSignature = (signingString: string, signature: string, a }; }; +function cartesianProduct(a: T[], b: U[]): [T, U][] { + return a.flatMap(a => b.map(b => [a, b] as [T, U])); +} + describe('ap-request', () => { test('createSignedPost with verify', async () => { const keypair = await genRsaKeyPair(); @@ -58,4 +63,110 @@ describe('ap-request', () => { const result = httpSignature.verifySignature(parsed, keypair.publicKey); assert.deepStrictEqual(result, true); }); + + /* + test('rejects non matching domain', () => { + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://alice.example.com/abc' } as IObject, + 'https://alice.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'validation should pass base case'); + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://bob.example.com/abc' } as IObject, + 'https://alice.example.com/abc', + FetchAllowSoftFailMask.Any, + ), 'validation should fail no matter what if the response URL is inconsistent with the object ID'); + + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc#test', + { id: 'https://alice.example.com/abc' } as IObject, + 'https://alice.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'validation should pass with hash in request URL'); + + // fix issues like threads + // https://github.com/misskey-dev/misskey/issues/15039 + const withOrWithoutWWW = [ + 'https://alice.example.com/abc', + 'https://www.alice.example.com/abc', + ]; + + cartesianProduct( + cartesianProduct( + withOrWithoutWWW, + withOrWithoutWWW, + ), + withOrWithoutWWW, + ).forEach(([[a, b], c]) => { + assert.doesNotThrow(() => assertActivityMatchesUrl( + a, + { id: b } as IObject, + c, + FetchAllowSoftFailMask.Strict, + ), 'validation should pass with or without www. subdomain'); + }); + }); + + test('cross origin lookup', () => { + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://bob.example.com/abc' } as IObject, + 'https://bob.example.com/abc', + FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId, + ), 'validation should pass if the response is otherwise consistent and cross-origin is allowed'); + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://bob.example.com/abc' } as IObject, + 'https://bob.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed'); + }); + + test('rejects non-canonical ID', () => { + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/@alice', + { id: 'https://alice.example.com/users/alice' } as IObject, + 'https://alice.example.com/users/alice', + FetchAllowSoftFailMask.Strict, + ), 'throws if the response ID did not exactly match the expected ID'); + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/@alice', + { id: 'https://alice.example.com/users/alice' } as IObject, + 'https://alice.example.com/users/alice', + FetchAllowSoftFailMask.NonCanonicalId, + ), 'does not throw if non-canonical ID is allowed'); + }); + + test('origin relaxed alignment', () => { + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://ap.alice.example.com/abc' } as IObject, + 'https://ap.alice.example.com/abc', + FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId, + ), 'validation should pass if response is a subdomain of the expected origin'); + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.multi-tenant.example.com/abc', + { id: 'https://alice.multi-tenant.example.com/abc' } as IObject, + 'https://bob.multi-tenant.example.com/abc', + FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId, + ), 'validation should fail if response is a disjoint domain of the expected origin'); + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://ap.alice.example.com/abc' } as IObject, + 'https://ap.alice.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'throws if relaxed origin is forbidden'); + }); + + test('resist HTTP downgrade', () => { + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://alice.example.com/abc' } as IObject, + 'http://alice.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'throws if HTTP downgrade is detected'); + }); + */ }); diff --git a/packages/backend/test/unit/core/HttpRequestService.ts b/packages/backend/test/unit/core/HttpRequestService.ts new file mode 100644 index 0000000000..a2f4604e7b --- /dev/null +++ b/packages/backend/test/unit/core/HttpRequestService.ts @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import type { Mock } from 'jest-mock'; +import type { PrivateNetwork } from '@/config.js'; +import type { Socket } from 'net'; +import { HttpRequestService, isPrivateIp, validateSocketConnect } from '@/core/HttpRequestService.js'; +import { parsePrivateNetworks } from '@/config.js'; + +describe(HttpRequestService, () => { + let allowedPrivateNetworks: PrivateNetwork[] | undefined; + + beforeEach(() => { + allowedPrivateNetworks = parsePrivateNetworks([ + '10.0.0.1/32', + { network: '127.0.0.1/32', ports: [1] }, + { network: '127.0.0.1/32', ports: [3, 4, 5] }, + ]); + }); + + describe('isPrivateIp', () => { + it('should return false when ip public', () => { + const result = isPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80); + expect(result).toBeFalsy(); + }); + + it('should return false when ip private and port matches', () => { + const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1); + expect(result).toBeFalsy(); + }); + + it('should return false when ip private and all ports undefined', () => { + const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined); + expect(result).toBeFalsy(); + }); + + it('should return true when ip private and no ports specified', () => { + const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80); + expect(result).toBeTruthy(); + }); + + it('should return true when ip private and port does not match', () => { + const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80); + expect(result).toBeTruthy(); + }); + + it('should return true when ip private and port is null but ports are specified', () => { + const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined); + expect(result).toBeTruthy(); + }); + }); + + describe('validateSocketConnect', () => { + let fakeSocket: Socket; + let fakeSocketMutable: { + remoteAddress: string | undefined; + remotePort: number | undefined; + destroy: Mock<(error?: Error) => void>; + }; + + beforeEach(() => { + fakeSocketMutable = { + remoteAddress: '74.125.127.100', + remotePort: 80, + destroy: jest.fn<(error?: Error) => void>(), + }; + fakeSocket = fakeSocketMutable as unknown as Socket; + }); + + it('should accept when IP is empty', () => { + fakeSocketMutable.remoteAddress = undefined; + + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).not.toHaveBeenCalled(); + }); + + it('should accept when IP is invalid', () => { + fakeSocketMutable.remoteAddress = 'AB939ajd9jdajsdja8jj'; + + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).not.toHaveBeenCalled(); + }); + + it('should accept when IP is valid', () => { + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).not.toHaveBeenCalled(); + }); + + it('should accept when IP is private and port match', () => { + fakeSocketMutable.remoteAddress = '127.0.0.1'; + fakeSocketMutable.remotePort = 1; + + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).not.toHaveBeenCalled(); + }); + + it('should reject when IP is private and port no match', () => { + fakeSocketMutable.remoteAddress = '127.0.0.1'; + fakeSocketMutable.remotePort = 2; + + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index e4f42809f8..4f45f3216d 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -4,6 +4,8 @@ */ import { Test, TestingModule } from '@nestjs/testing'; +import { FakeInternalEventService } from '../../misc/FakeInternalEventService.js'; +import { NoOpCacheService } from '../../misc/noOpCaches.js'; import type { MiUser } from '@/models/User.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalModule } from '@/GlobalModule.js'; @@ -50,6 +52,8 @@ import { AccountMoveService } from '@/core/AccountMoveService.js'; import { ReactionService } from '@/core/ReactionService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { ChatService } from '@/core/ChatService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; process.env.NODE_ENV = 'test'; @@ -73,7 +77,7 @@ describe('UserEntityService', () => { ...userData, id: genAidx(Date.now()), username: un, - usernameLower: un, + usernameLower: un.toLowerCase(), }) .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); @@ -172,6 +176,8 @@ describe('UserEntityService', () => { ReactionService, ReactionsBufferingService, NotificationService, + ChatService, + InternalEventService, ]; app = await Test.createTestingModule({ @@ -180,7 +186,10 @@ describe('UserEntityService', () => { ...services, ...services.map(x => ({ provide: x.name, useExisting: x })), ], - }).compile(); + }) + .overrideProvider(InternalEventService).useClass(FakeInternalEventService) + .overrideProvider(CacheService).useClass(NoOpCacheService) + .compile(); await app.init(); app.enableShutdownHooks(); diff --git a/packages/backend/test/unit/extract-mentions.ts b/packages/backend/test/unit/extract-mentions.ts index 2aad89d65b..3403387e30 100644 --- a/packages/backend/test/unit/extract-mentions.ts +++ b/packages/backend/test/unit/extract-mentions.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; -import { parse } from '@transfem-org/sfm-js'; +import { parse } from 'mfm-js'; import { extractMentions } from '@/misc/extract-mentions.js'; describe('Extract mentions', () => { diff --git a/packages/backend/test/unit/misc/QuantumKVCache.ts b/packages/backend/test/unit/misc/QuantumKVCache.ts new file mode 100644 index 0000000000..92792171be --- /dev/null +++ b/packages/backend/test/unit/misc/QuantumKVCache.ts @@ -0,0 +1,799 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { FakeInternalEventService } from '../../misc/FakeInternalEventService.js'; +import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js'; + +describe(QuantumKVCache, () => { + let fakeInternalEventService: FakeInternalEventService; + let madeCaches: { dispose: () => void }[]; + + function makeCache(opts?: Partial> & { name?: string }): QuantumKVCache { + const _opts = { + name: 'test', + lifetime: Infinity, + fetcher: () => { throw new Error('not implemented'); }, + } satisfies QuantumKVOpts & { name: string }; + + if (opts) { + Object.assign(_opts, opts); + } + + const cache = new QuantumKVCache(fakeInternalEventService, _opts.name, _opts); + madeCaches.push(cache); + return cache; + } + + beforeEach(() => { + madeCaches = []; + fakeInternalEventService = new FakeInternalEventService(); + }); + + afterEach(() => { + madeCaches.forEach(cache => { + cache.dispose(); + }); + }); + + it('should connect on construct', () => { + makeCache(); + + expect(fakeInternalEventService._calls).toContainEqual(['on', ['quantumCacheUpdated', expect.anything(), { ignoreLocal: true }]]); + }); + + it('should disconnect on dispose', () => { + const cache = makeCache(); + + cache.dispose(); + + const callback = fakeInternalEventService._calls + .find(c => c[0] === 'on' && c[1][0] === 'quantumCacheUpdated') + ?.[1][1]; + expect(fakeInternalEventService._calls).toContainEqual(['off', ['quantumCacheUpdated', callback]]); + }); + + it('should store in memory cache', async () => { + const cache = makeCache(); + + await cache.set('foo', 'bar'); + await cache.set('alpha', 'omega'); + + const result1 = await cache.get('foo'); + const result2 = await cache.get('alpha'); + + expect(result1).toBe('bar'); + expect(result2).toBe('omega'); + }); + + it('should emit event when storing', async () => { + const cache = makeCache({ name: 'fake' }); + + await cache.set('foo', 'bar'); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); + }); + + it('should call onChanged when storing', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + + await cache.set('foo', 'bar'); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); + }); + + it('should not emit event when storing unchanged value', async () => { + const cache = makeCache({ name: 'fake' }); + + await cache.set('foo', 'bar'); + await cache.set('foo', 'bar'); + + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + + it('should not call onChanged when storing unchanged value', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + + await cache.set('foo', 'bar'); + await cache.set('foo', 'bar'); + + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should fetch an unknown value', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + const result = await cache.fetch('foo'); + + expect(result).toBe('value#foo'); + }); + + it('should store fetched value in memory cache', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.fetch('foo'); + + const result = cache.has('foo'); + expect(result).toBe(true); + }); + + it('should call onChanged when fetching', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + onChanged: fakeOnChanged, + }); + + await cache.fetch('foo'); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); + }); + + it('should not emit event when fetching', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.fetch('foo'); + + expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); + }); + + it('should delete from memory cache', async () => { + const cache = makeCache(); + + await cache.set('foo', 'bar'); + await cache.delete('foo'); + + const result = cache.has('foo'); + expect(result).toBe(false); + }); + + it('should call onChanged when deleting', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + + await cache.set('foo', 'bar'); + await cache.delete('foo'); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); + }); + + it('should emit event when deleting', async () => { + const cache = makeCache({ name: 'fake' }); + + await cache.set('foo', 'bar'); + await cache.delete('foo'); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); + }); + + it('should delete when receiving set event', async () => { + const cache = makeCache({ name: 'fake' }); + await cache.set('foo', 'bar'); + + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] }); + + const result = cache.has('foo'); + expect(result).toBe(false); + }); + + it('should call onChanged when receiving set event', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] }); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); + }); + + it('should delete when receiving delete event', async () => { + const cache = makeCache({ name: 'fake' }); + await cache.set('foo', 'bar'); + + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] }); + + const result = cache.has('foo'); + expect(result).toBe(false); + }); + + it('should call onChanged when receiving delete event', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + await cache.set('foo', 'bar'); + + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] }); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); + }); + + describe('get', () => { + it('should return value if present', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = cache.get('foo'); + + expect(result).toBe('bar'); + }); + it('should return undefined if missing', () => { + const cache = makeCache(); + + const result = cache.get('foo'); + + expect(result).toBe(undefined); + }); + }); + + describe('setMany', () => { + it('should populate all values', async () => { + const cache = makeCache(); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(cache.has('foo')).toBe(true); + expect(cache.has('alpha')).toBe(true); + }); + + it('should emit one event', async () => { + const cache = makeCache({ + name: 'fake', + }); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + + it('should call onChanged once with all items', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should emit events only for changed items', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + + await cache.set('foo', 'bar'); + fakeOnChanged.mockClear(); + fakeInternalEventService._reset(); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + expect(fakeOnChanged).toHaveBeenCalledWith(['alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + }); + + describe('getMany', () => { + it('should return empty for empty input', () => { + const cache = makeCache(); + const result = cache.getMany([]); + expect(result).toEqual([]); + }); + + it('should return the value for all keys', () => { + const cache = makeCache(); + cache.add('foo', 'bar'); + cache.add('alpha', 'omega'); + + const result = cache.getMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'bar'], ['alpha', 'omega']]); + }); + + it('should return undefined for missing keys', () => { + const cache = makeCache(); + cache.add('foo', 'bar'); + + const result = cache.getMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'bar'], ['alpha', undefined]]); + }); + }); + + describe('fetchMany', () => { + it('should do nothing for empty input', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + }); + + await cache.fetchMany([]); + + expect(fakeOnChanged).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should return existing items', async () => { + const cache = makeCache(); + cache.add('foo', 'bar'); + cache.add('alpha', 'omega'); + + const result = await cache.fetchMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'bar'], ['alpha', 'omega']]); + }); + + it('should return existing items without events', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + }); + cache.add('foo', 'bar'); + cache.add('alpha', 'omega'); + + await cache.fetchMany(['foo', 'alpha']); + + expect(fakeOnChanged).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should call bulkFetcher for missing items', async () => { + const cache = makeCache({ + bulkFetcher: keys => keys.map(k => [k, `${k}#many`]), + fetcher: key => `${key}#single`, + }); + + const results = await cache.fetchMany(['foo', 'alpha']); + + expect(results).toEqual([['foo', 'foo#many'], ['alpha', 'alpha#many']]); + }); + + it('should call bulkFetcher only once', async () => { + const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string])); + const cache = makeCache({ + bulkFetcher: mockBulkFetcher, + }); + + await cache.fetchMany(['foo', 'bar']); + + expect(mockBulkFetcher).toHaveBeenCalledTimes(1); + }); + + it('should call fetcher when fetchMany is undefined', async () => { + const cache = makeCache({ + fetcher: key => `${key}#single`, + }); + + const results = await cache.fetchMany(['foo', 'alpha']); + + expect(results).toEqual([['foo', 'foo#single'], ['alpha', 'alpha#single']]); + }); + + it('should call onChanged', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + fetcher: k => k, + }); + + await cache.fetchMany(['foo', 'alpha']); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should call onChanged only for changed', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + fetcher: k => k, + }); + cache.add('foo', 'bar'); + + await cache.fetchMany(['foo', 'alpha']); + + expect(fakeOnChanged).toHaveBeenCalledWith(['alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should not emit event', async () => { + const cache = makeCache({ + fetcher: k => k, + }); + + await cache.fetchMany(['foo', 'alpha']); + + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + }); + + describe('refreshMany', () => { + it('should do nothing for empty input', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + }); + + const result = await cache.refreshMany([]); + + expect(result).toEqual([]); + expect(fakeOnChanged).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should call bulkFetcher for all keys', async () => { + const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string])); + const cache = makeCache({ + bulkFetcher: mockBulkFetcher, + }); + + const result = await cache.refreshMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'foo#value'], ['alpha', 'alpha#value']]); + expect(mockBulkFetcher).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(mockBulkFetcher).toHaveBeenCalledTimes(1); + }); + + it('should replace any existing keys', async () => { + const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string])); + const cache = makeCache({ + bulkFetcher: mockBulkFetcher, + }); + cache.add('foo', 'bar'); + + const result = await cache.refreshMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'foo#value'], ['alpha', 'alpha#value']]); + expect(mockBulkFetcher).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(mockBulkFetcher).toHaveBeenCalledTimes(1); + }); + + it('should call onChanged for all keys', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + bulkFetcher: keys => keys.map(k => [k, `${k}#value`]), + onChanged: fakeOnChanged, + }); + cache.add('foo', 'bar'); + + await cache.refreshMany(['foo', 'alpha']); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should emit event for all keys', async () => { + const cache = makeCache({ + name: 'fake', + bulkFetcher: keys => keys.map(k => [k, `${k}#value`]), + }); + cache.add('foo', 'bar'); + + await cache.refreshMany(['foo', 'alpha']); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + }); + + describe('deleteMany', () => { + it('should remove keys from memory cache', async () => { + const cache = makeCache(); + + await cache.set('foo', 'bar'); + await cache.set('alpha', 'omega'); + await cache.deleteMany(['foo', 'alpha']); + + expect(cache.has('foo')).toBe(false); + expect(cache.has('alpha')).toBe(false); + }); + + it('should emit only one event', async () => { + const cache = makeCache({ + name: 'fake', + }); + + await cache.deleteMany(['foo', 'alpha']); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + + it('should call onChanged once with all items', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + + await cache.deleteMany(['foo', 'alpha']); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should do nothing if no keys are provided', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onChanged: fakeOnChanged, + }); + + await cache.deleteMany([]); + + expect(fakeOnChanged).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + }); + + describe('refresh', () => { + it('should populate the value', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.refresh('foo'); + + const result = cache.has('foo'); + expect(result).toBe(true); + }); + + it('should return the value', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + const result = await cache.refresh('foo'); + + expect(result).toBe('value#foo'); + }); + + it('should replace the value if it exists', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.set('foo', 'bar'); + const result = await cache.refresh('foo'); + + expect(result).toBe('value#foo'); + }); + + it('should call onChanged', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + onChanged: fakeOnChanged, + }); + + await cache.refresh('foo'); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); + }); + + it('should emit event', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.refresh('foo'); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); + }); + }); + + describe('add', () => { + it('should add the item', () => { + const cache = makeCache(); + cache.add('foo', 'bar'); + expect(cache.has('foo')).toBe(true); + }); + + it('should not emit event', () => { + const cache = makeCache({ + name: 'fake', + }); + + cache.add('foo', 'bar'); + + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should not call onChanged', () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + }); + + cache.add('foo', 'bar'); + + expect(fakeOnChanged).not.toHaveBeenCalled(); + }); + }); + + describe('addMany', () => { + it('should add all items', () => { + const cache = makeCache(); + + cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(cache.has('foo')).toBe(true); + expect(cache.has('alpha')).toBe(true); + }); + + it('should not emit event', () => { + const cache = makeCache({ + name: 'fake', + }); + + cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should not call onChanged', () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + }); + + cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeOnChanged).not.toHaveBeenCalled(); + }); + }); + + describe('has', () => { + it('should return false when empty', () => { + const cache = makeCache(); + const result = cache.has('foo'); + expect(result).toBe(false); + }); + + it('should return false when value is not in memory', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = cache.has('alpha'); + + expect(result).toBe(false); + }); + + it('should return true when value is in memory', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = cache.has('foo'); + + expect(result).toBe(true); + }); + }); + + describe('size', () => { + it('should return 0 when empty', () => { + const cache = makeCache(); + expect(cache.size).toBe(0); + }); + + it('should return correct size when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + expect(cache.size).toBe(1); + }); + }); + + describe('entries', () => { + it('should return empty when empty', () => { + const cache = makeCache(); + + const result = Array.from(cache.entries()); + + expect(result).toHaveLength(0); + }); + + it('should return all entries when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = Array.from(cache.entries()); + + expect(result).toEqual([['foo', 'bar']]); + }); + }); + + describe('keys', () => { + it('should return empty when empty', () => { + const cache = makeCache(); + + const result = Array.from(cache.keys()); + + expect(result).toHaveLength(0); + }); + + it('should return all keys when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = Array.from(cache.keys()); + + expect(result).toEqual(['foo']); + }); + }); + + describe('values', () => { + it('should return empty when empty', () => { + const cache = makeCache(); + + const result = Array.from(cache.values()); + + expect(result).toHaveLength(0); + }); + + it('should return all values when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = Array.from(cache.values()); + + expect(result).toEqual(['bar']); + }); + }); + + describe('[Symbol.iterator]', () => { + it('should return empty when empty', () => { + const cache = makeCache(); + + const result = Array.from(cache); + + expect(result).toHaveLength(0); + }); + + it('should return all entries when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = Array.from(cache); + + expect(result).toEqual([['foo', 'bar']]); + }); + }); +}); diff --git a/packages/backend/test/unit/misc/diff-arrays.ts b/packages/backend/test/unit/misc/diff-arrays.ts new file mode 100644 index 0000000000..b6db5e2eca --- /dev/null +++ b/packages/backend/test/unit/misc/diff-arrays.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js'; + +describe(diffArrays, () => { + it('should return empty result when both inputs are null', () => { + const result = diffArrays(null, null); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + }); + + it('should return empty result when both inputs are empty', () => { + const result = diffArrays([], []); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + }); + + it('should remove before when after is empty', () => { + const result = diffArrays([1, 2, 3], []); + expect(result.added).toHaveLength(0); + expect(result.removed).toEqual([1, 2, 3]); + }); + + it('should deduplicate before when after is empty', () => { + const result = diffArrays([1, 1, 2, 2, 3], []); + expect(result.added).toHaveLength(0); + expect(result.removed).toEqual([1, 2, 3]); + }); + + it('should add after when before is empty', () => { + const result = diffArrays([], [1, 2, 3]); + expect(result.added).toEqual([1, 2, 3]); + expect(result.removed).toHaveLength(0); + }); + + it('should deduplicate after when before is empty', () => { + const result = diffArrays([], [1, 1, 2, 2, 3]); + expect(result.added).toEqual([1, 2, 3]); + expect(result.removed).toHaveLength(0); + }); + + it('should return diff when both have values', () => { + const result = diffArrays( + ['a', 'b', 'c', 'd'], + ['a', 'c', 'e', 'f'], + ); + expect(result.added).toEqual(['e', 'f']); + expect(result.removed).toEqual(['b', 'd']); + }); +}); + +describe(diffArraysSimple, () => { + it('should return false when both inputs are null', () => { + const result = diffArraysSimple(null, null); + expect(result).toBe(false); + }); + + it('should return false when both inputs are empty', () => { + const result = diffArraysSimple([], []); + expect(result).toBe(false); + }); + + it('should return true when before is populated and after is empty', () => { + const result = diffArraysSimple([1, 2, 3], []); + expect(result).toBe(true); + }); + + it('should return true when before is empty and after is populated', () => { + const result = diffArraysSimple([], [1, 2, 3]); + expect(result).toBe(true); + }); + + it('should return true when values have changed', () => { + const result = diffArraysSimple( + ['a', 'a', 'b', 'c'], + ['a', 'b', 'c', 'd'], + ); + expect(result).toBe(true); + }); + + it('should return false when values have not changed', () => { + const result = diffArraysSimple( + ['a', 'a', 'b', 'c'], + ['a', 'b', 'c', 'c'], + ); + expect(result).toBe(false); + }); +}); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 24cd2236bb..b6cfa53466 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -40,10 +40,13 @@ const base: MiNote = { channelId: null, channel: null, userHost: null, + userInstance: null, replyUserId: null, replyUserHost: null, + replyUserInstance: null, renoteUserId: null, renoteUserHost: null, + renoteUserInstance: null, processErrors: [], }; diff --git a/packages/backend/test/unit/misc/is-retryable-error.ts b/packages/backend/test/unit/misc/is-retryable-error.ts index 096bf64d4f..6d241066f7 100644 --- a/packages/backend/test/unit/misc/is-retryable-error.ts +++ b/packages/backend/test/unit/misc/is-retryable-error.ts @@ -8,6 +8,9 @@ import { AbortError } from 'node-fetch'; import { isRetryableError } from '@/misc/is-retryable-error.js'; import { StatusError } from '@/misc/status-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { CaptchaError, captchaErrorCodes } from '@/core/CaptchaService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; +import { ConflictError } from '@/server/SkRateLimiterService.js'; describe(isRetryableError, () => { it('should return true for retryable StatusError', () => { @@ -55,6 +58,78 @@ describe(isRetryableError, () => { expect(result).toBeTruthy(); }); + it('should return false for CaptchaError with verificationFailed', () => { + const error = new CaptchaError(captchaErrorCodes.verificationFailed, 'verificationFailed'); + const result = isRetryableError(error); + expect(result).toBeFalsy(); + }); + + it('should return false for CaptchaError with invalidProvider', () => { + const error = new CaptchaError(captchaErrorCodes.invalidProvider, 'invalidProvider'); + const result = isRetryableError(error); + expect(result).toBeFalsy(); + }); + + it('should return false for CaptchaError with invalidParameters', () => { + const error = new CaptchaError(captchaErrorCodes.invalidParameters, 'invalidParameters'); + const result = isRetryableError(error); + expect(result).toBeFalsy(); + }); + + it('should return true for CaptchaError with noResponseProvided', () => { + const error = new CaptchaError(captchaErrorCodes.noResponseProvided, 'noResponseProvided'); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return true for CaptchaError with requestFailed', () => { + const error = new CaptchaError(captchaErrorCodes.requestFailed, 'requestFailed'); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return true for CaptchaError with unknown', () => { + const error = new CaptchaError(captchaErrorCodes.unknown, 'unknown'); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return true for CaptchaError with any other', () => { + const error = new CaptchaError(Symbol('temp'), 'unknown'); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return false for FastifyReplyError', () => { + const error = new FastifyReplyError(400, 'test error'); + const result = isRetryableError(error); + expect(result).toBeFalsy(); + }); + + it('should return true for ConflictError', () => { + const error = new ConflictError('test error'); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return true for AggregateError when all inners are retryable', () => { + const error = new AggregateError([ + new ConflictError(), + new ConflictError(), + ]); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return true for AggregateError when any error is not retryable', () => { + const error = new AggregateError([ + new ConflictError(), + new StatusError('test err', 400), + ]); + const result = isRetryableError(error); + expect(result).toBeFalsy(); + }); + const nonErrorInputs = [ [null, 'null'], [undefined, 'undefined'], diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts index d96e6b916a..07618e7762 100644 --- a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts +++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -316,7 +316,7 @@ describe('CheckModeratorsActivityProcessorService', () => { createUser({}, { email: 'user2@example.com', emailVerified: false }), createUser({}, { email: null, emailVerified: false }), createUser({}, { email: 'user4@example.com', emailVerified: true }), - createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }), + createUser({}, { email: 'root@example.com', emailVerified: true }), ]); mockModeratorRole([user1, user2, user3, root]); @@ -349,7 +349,7 @@ describe('CheckModeratorsActivityProcessorService', () => { createUser({}, { email: 'user2@example.com', emailVerified: false }), createUser({}, { email: null, emailVerified: false }), createUser({}, { email: 'user4@example.com', emailVerified: true }), - createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }), + createUser({}, { email: 'root@example.com', emailVerified: true }), ]); mockModeratorRole([user1, user2, user3, root]); diff --git a/packages/backend/test/unit/server/api/drive/files/create.ts b/packages/backend/test/unit/server/api/drive/files/create.ts new file mode 100644 index 0000000000..9b38f4d744 --- /dev/null +++ b/packages/backend/test/unit/server/api/drive/files/create.ts @@ -0,0 +1,164 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { FastifyInstance } from 'fastify'; +import request from 'supertest'; +import { randomString } from '../../../../../utils.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DriveFoldersRepository, MiDriveFolder, MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { MiUser } from '@/models/User.js'; +import { ServerModule } from '@/server/ServerModule.js'; +import { ServerService } from '@/server/ServerService.js'; +import { IdService } from '@/core/IdService.js'; + +describe('/drive/files/create', () => { + let module: TestingModule; + let server: FastifyInstance; + let roleService: RoleService; + let idService: IdService; + + let root: MiUser; + let role_tinyAttachment: MiRole; + + let folder: MiDriveFolder; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule, ServerModule], + }).compile(); + module.enableShutdownHooks(); + + const serverService = module.get(ServerService); + await serverService.launch(); + server = serverService.fastify; + + idService = module.get(IdService); + + const usersRepository = module.get(DI.usersRepository); + await usersRepository.delete({}); + root = await usersRepository.insert({ + id: idService.gen(), + username: 'root', + usernameLower: 'root', + token: '1234567890123456', + }).then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + const userProfilesRepository = module.get(DI.userProfilesRepository); + await userProfilesRepository.delete({}); + await userProfilesRepository.insert({ + userId: root.id, + }); + + const driveFoldersRepository = module.get(DI.driveFoldersRepository); + folder = await driveFoldersRepository.insertOne({ + id: idService.gen(), + name: 'root-folder', + parentId: null, + userId: root.id, + }); + + roleService = module.get(RoleService); + role_tinyAttachment = await roleService.create({ + name: 'test-role001', + description: 'Test role001 description', + target: 'manual', + policies: { + maxFileSizeMb: { + useDefault: false, + priority: 1, + // 10byte + value: 10 / 1024 / 1024, + }, + }, + }); + }); + + beforeEach(async () => { + await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => { + }); + }); + + afterAll(async () => { + await server.close(); + await module.close(); + }); + + async function postFile(props: { + name: string, + comment: string, + isSensitive: boolean, + force: boolean, + fileContent: Buffer | string, + }) { + const { name, comment, isSensitive, force, fileContent } = props; + + return await request(server.server) + .post('/api/drive/files/create') + .set('Content-Type', 'multipart/form-data') + .attach('file', fileContent) + .field('name', name) + .field('comment', comment) + .field('isSensitive', isSensitive) + .field('force', force) + .field('folderId', folder.id) + .field('i', root.token ?? ''); + } + + test('200 ok', async () => { + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(1000 * 1000)), + }); + expect(result.statusCode).toBe(200); + expect(result.body.name).toBe(name + '.unknown'); + expect(result.body.comment).toBe(comment); + expect(result.body.isSensitive).toBe(true); + expect(result.body.folderId).toBe(folder.id); + }); + + test('200 ok(with role)', async () => { + await roleService.assign(root.id, role_tinyAttachment.id); + + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(10)), + }); + expect(result.statusCode).toBe(200); + expect(result.body.name).toBe(name + '.unknown'); + expect(result.body.comment).toBe(comment); + expect(result.body.isSensitive).toBe(true); + expect(result.body.folderId).toBe(folder.id); + }); + + test('413 too large', async () => { + await roleService.assign(root.id, role_tinyAttachment.id); + + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(11)), + }); + expect(result.statusCode).toBe(413); + expect(result.body.error.code).toBe('MAX_FILE_SIZE_EXCEEDED'); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index cf97473d14..52cfb8ac93 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -11,11 +11,12 @@ import { inspect } from 'node:util'; import WebSocket, { ClientOptions } from 'ws'; import fetch, { File, RequestInit, type Headers } from 'node-fetch'; import { DataSource } from 'typeorm'; -import { JSDOM } from 'jsdom'; +import { load as cheerio } from 'cheerio/slim'; import { type Response } from 'node-fetch'; import Fastify from 'fastify'; import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; +import type { CheerioAPI } from 'cheerio/slim'; import type * as misskey from 'misskey-js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; @@ -35,7 +36,7 @@ export type SystemWebhookPayload = { createdAt: string; type: string; body: any; -} +}; const config = loadConfig(); export const port = config.port; @@ -45,10 +46,6 @@ export const host = new URL(config.url).host; export const WEBHOOK_HOST = 'http://localhost:15080'; export const WEBHOOK_PORT = 15080; -export const cookie = (me: UserToken): string => { - return `token=${me.token};`; -}; - export type ApiRequest = { endpoint: E, parameters: P, @@ -468,7 +465,7 @@ export function makeStreamCatcher( export type SimpleGetResponse = { status: number, - body: any | JSDOM | null, + body: any | CheerioAPI | null, type: string | null, location: string | null }; @@ -499,7 +496,7 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde const body = jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : - htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? cheerio(await res.text()) : await bodyExtractor(res); return { @@ -655,7 +652,7 @@ export async function sendEnvResetRequest() { // 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。 // FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する -export function castAsError(obj: Record): { error: ApiError } { +export function castAsError(obj: object | null | undefined): { error: ApiError } { return obj as { error: ApiError }; } @@ -665,7 +662,9 @@ export async function captureWebhook(postAction: () => let timeoutHandle: NodeJS.Timeout | null = null; const result = await new Promise(async (resolve, reject) => { fastify.all('/', async (req, res) => { - timeoutHandle && clearTimeout(timeoutHandle); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } const body = JSON.stringify(req.body); res.status(200).send('ok'); diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 392da169ad..afed1f186c 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -23,6 +23,7 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "incremental": true, "rootDir": "./src", "baseUrl": "./", "paths": { diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index e686da9cc3..fbe91f3660 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -52,6 +52,7 @@ export default [ '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため 'id-denylist': ['error', 'window', 'e'], diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 2ee60eaad8..6cdfd8f3f1 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -11,61 +11,60 @@ }, "dependencies": { "@discordapp/twemoji": "15.1.0", - "@phosphor-icons/web": "^2.0.3", - "@rollup/plugin-json": "6.1.0", - "@rollup/plugin-replace": "5.0.7", - "@rollup/pluginutils": "5.1.3", - "@transfem-org/sfm-js": "0.24.5", - "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.2.0", - "@vue/compiler-sfc": "3.5.12", - "astring": "1.9.0", + "@phosphor-icons/web": "2.1.2", + "mfm-js": "npm:@transfem-org/sfm-js@0.24.6", "buraha": "0.0.1", - "estree-walker": "3.0.3", - "misskey-js": "workspace:*", "frontend-shared": "workspace:*", - "punycode.js": "2.3.1", - "rollup": "4.26.0", - "sass": "1.79.4", - "shiki": "1.22.2", - "tinycolor2": "1.6.0", - "tsc-alias": "1.8.10", - "tsconfig-paths": "4.2.0", - "typescript": "5.6.3", - "uuid": "10.0.0", "json5": "2.2.3", - "vite": "5.4.11", - "vue": "3.5.12" + "misskey-js": "workspace:*", + "punycode.js": "2.3.1", + "shiki": "3.3.0", + "tinycolor2": "1.6.0", + "uuid": "11.1.0", + "vue": "3.5.14" }, "devDependencies": { - "@transfem-org/summaly": "5.2.1", + "@misskey-dev/summaly": "npm:@transfem-org/summaly@5.2.2", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.1.4", "@testing-library/vue": "8.1.0", - "@types/estree": "1.0.6", + "@twemoji/parser": "15.1.1", + "@types/estree": "1.0.7", "@types/micromatch": "4.0.9", - "@types/node": "22.9.0", + "@types/node": "22.15.2", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", - "@types/uuid": "10.0.0", - "@types/ws": "8.5.13", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", - "@vitest/coverage-v8": "1.6.0", - "@vue/runtime-core": "3.5.12", - "acorn": "8.14.0", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.31.0", + "@typescript-eslint/parser": "8.31.0", + "@vitejs/plugin-vue": "5.2.3", + "@vitest/coverage-v8": "3.1.2", + "@vue/compiler-sfc": "3.5.14", + "@vue/runtime-core": "3.5.14", + "acorn": "8.14.1", + "astring": "1.9.0", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", - "eslint-plugin-vue": "9.31.0", - "fast-glob": "3.3.2", - "happy-dom": "10.0.3", + "eslint-plugin-vue": "10.0.0", + "estree-walker": "3.0.3", + "fast-glob": "3.3.3", + "happy-dom": "17.4.4", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.6.4", - "nodemon": "3.1.7", - "prettier": "3.3.3", - "start-server-and-test": "2.0.8", + "msw": "2.7.5", + "nodemon": "3.1.10", + "prettier": "3.5.3", + "rollup": "4.40.0", + "sass": "1.87.0", + "start-server-and-test": "2.0.11", + "tsc-alias": "1.8.15", + "tsconfig-paths": "4.2.0", + "typescript": "5.8.3", + "vite": "6.3.3", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "2.1.10", - "vue-eslint-parser": "9.4.3", - "vue-tsc": "2.1.10" + "vue-component-type-helpers": "2.2.10", + "vue-eslint-parser": "10.1.3", + "vue-tsc": "2.2.10" } } diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts index 7c8336ce3f..f11cfef8fd 100644 --- a/packages/frontend-embed/src/boot.ts +++ b/packages/frontend-embed/src/boot.ts @@ -28,7 +28,7 @@ console.log('Sharkey Embed'); //#region Embedパラメータの取得・パース const params = new URLSearchParams(location.search); const embedParams = parseEmbedParams(params); -if (_DEV_) console.log(embedParams); +if (_DEV_) console.debug(embedParams); //#endregion //#region テーマ diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue index bf976c71ae..0bff048ce4 100644 --- a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue +++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue @@ -33,13 +33,11 @@ const canvasPromise = new Promise(resol Math.min(navigator.hardwareConcurrency - 1, 4), ); resolve(workers); - if (_DEV_) console.log('WebGL2 in worker is supported!'); } else { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); - if (_DEV_) console.log('WebGL2 in worker is not supported...'); } testWorker.terminate(); }); diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue index 3bdf702b01..e9126d4665 100644 --- a/packages/frontend-embed/src/components/EmMediaImage.vue +++ b/packages/frontend-embed/src/components/EmMediaImage.vue @@ -95,7 +95,7 @@ async function onclick(ev: MouseEvent) { position: absolute; border-radius: 6px; background-color: var(--MI_THEME-fg); - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); font-size: 12px; opacity: .5; padding: 5px 8px; @@ -153,7 +153,7 @@ html[data-color-scheme=light] .visible { /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: 6px; - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); display: inline-block; font-weight: bold; font-size: 0.8em; diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts index 40189133d2..74ae3373ef 100644 --- a/packages/frontend-embed/src/components/EmMfm.ts +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { VNode, h, SetupContext, provide } from 'vue'; -import * as mfm from '@transfem-org/sfm-js'; +import { h, provide } from 'vue'; +import type { VNode, SetupContext } from 'vue'; +import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import EmUrl from '@/components/EmUrl.vue'; diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue index bf96c557ea..0dc77d09a7 100644 --- a/packages/frontend-embed/src/components/EmNote.vue +++ b/packages/frontend-embed/src/components/EmNote.vue @@ -105,7 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue index 0961b36e35..8a10778e8a 100644 --- a/packages/frontend-embed/src/components/EmNoteDetailed.vue +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -64,17 +64,17 @@ SPDX-License-Identifier: AGPL-3.0-only

({{ i18n.ts.private }}) - - - RN: +
+ + +
@@ -128,7 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue index 688758edb6..a1dee733c7 100644 --- a/packages/frontend-embed/src/components/EmNoteSimple.vue +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -36,7 +36,6 @@ const props = defineProps<{ }>(); const showContent = ref(false); - const mergedCW = computed(() => computeMergedCw(props.note)); diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue index 629f0bffcd..931e1e2d79 100644 --- a/packages/frontend-embed/src/components/EmNoteSub.vue +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -55,7 +55,6 @@ const props = withDefaults(defineProps<{ const showContent = ref(false); const replies = ref([]); - const mergedCW = computed(() => computeMergedCw(props.note)); if (props.detail) { diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue index 4e0ae005df..962a982fb7 100644 --- a/packages/frontend-embed/src/components/EmNotes.vue +++ b/packages/frontend-embed/src/components/EmNotes.vue @@ -22,7 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend-embed/src/pages/note.vue b/packages/frontend-embed/src/pages/note.vue index e879430286..4e0ea9c145 100644 --- a/packages/frontend-embed/src/pages/note.vue +++ b/packages/frontend-embed/src/pages/note.vue @@ -17,7 +17,7 @@ import EmNoteDetailed from '@/components/EmNoteDetailed.vue'; import XNotFound from '@/pages/not-found.vue'; import { DI } from '@/di.js'; import { misskeyApi } from '@/misskey-api.js'; -import { assertServerContext } from '@/server-context'; +import { assertServerContext } from '@/server-context.js'; const props = defineProps<{ noteId: string; diff --git a/packages/frontend-embed/src/post-message.ts b/packages/frontend-embed/src/post-message.ts index fd8eb8a5d2..f44b58acd3 100644 --- a/packages/frontend-embed/src/post-message.ts +++ b/packages/frontend-embed/src/post-message.ts @@ -21,14 +21,14 @@ export type MiPostMessageEvent { await context.dispose(); diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js index 689f7870c0..ff4d27443b 100644 --- a/packages/frontend-shared/eslint.config.js +++ b/packages/frontend-shared/eslint.config.js @@ -52,6 +52,7 @@ export default [ '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため 'id-denylist': ['error', 'window', 'e'], @@ -99,6 +100,10 @@ export default [ }, { ignores: [ + // TODO: Error while loading rule '@typescript-eslint/naming-convention': Cannot use 'in' operator to search for 'type' in undefined のため一時的に無効化 + // See https://github.com/misskey-dev/misskey/pull/15311 + 'js/i18n.ts', + 'js-built/', "**/lib/", "**/temp/", "**/built/", diff --git a/packages/frontend-shared/js/collapsed.ts b/packages/frontend-shared/js/collapsed.ts index af1f88cb73..aa24c43bcb 100644 --- a/packages/frontend-shared/js/collapsed.ts +++ b/packages/frontend-shared/js/collapsed.ts @@ -6,17 +6,30 @@ import * as Misskey from 'misskey-js'; export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean { - const collapsed = note.cw == null && ( - (note.text != null && ( - (note.text.includes('$[x2')) || - (note.text.includes('$[x3')) || - (note.text.includes('$[x4')) || - (note.text.includes('$[scale')) || - (note.text.split('\n').length > 9) || - (note.text.length > 500) || - (urls.length >= 4) - )) || (note.files != null && note.files.length >= 5) - ); + if (note.cw != null) { + return false; + } - return collapsed; + if (note.text != null) { + if ( + note.text.includes('$[x2') || + note.text.includes('$[x3') || + note.text.includes('$[x4') || + note.text.includes('$[scale') || + note.text.split('\n').length > 9 || + note.text.length > 500 + ) { + return true; + } + } + + if (urls.length >= 4) { + return true; + } + + if (note.files != null && note.files.length >= 5) { + return true; + } + + return false; } diff --git a/packages/frontend-shared/js/config.ts b/packages/frontend-shared/js/config.ts index 9a23e0e7f3..4c739e6e64 100644 --- a/packages/frontend-shared/js/config.ts +++ b/packages/frontend-shared/js/config.ts @@ -12,6 +12,7 @@ const siteName = document.querySelector('meta[property="og:site export const host = address.host; export const hostname = address.hostname; export const url = address.origin; +export const port = address.port; export const apiUrl = location.origin + '/api'; export const wsOrigin = location.origin; export const lang = localStorage.getItem('lang') ?? 'en-US'; diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 0dac166749..2865656157 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -126,9 +126,11 @@ export const notificationTypes = [ 'receiveFollowRequest', 'followRequestAccepted', 'roleAssigned', + 'chatRoomInvitationReceived', 'achievementEarned', 'exportCompleted', 'login', + 'createToken', 'test', 'app', 'edited', @@ -155,6 +157,7 @@ export const ROLE_POLICIES = [ 'canUseTranslator', 'canHideAds', 'driveCapacityMb', + 'maxFileSizeMb', 'alwaysMarkNsfw', 'canUpdateBioMedia', 'pinLimit', @@ -172,14 +175,10 @@ export const ROLE_POLICIES = [ 'canImportFollowing', 'canImportMuting', 'canImportUserLists', + 'chatAvailability', + 'canTrend', ] as const; -// なんか動かない -//export const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP'); -//export const CURRENT_STICKY_BOTTOM = Symbol('CURRENT_STICKY_BOTTOM'); -export const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP'; -export const CURRENT_STICKY_BOTTOM = 'CURRENT_STICKY_BOTTOM'; - export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/client-assets/status/error.png'; export const DEFAULT_NOT_FOUND_IMAGE_URL = '/client-assets/status/missingpage.webp'; export const DEFAULT_INFO_IMAGE_URL = '/client-assets/status/nothinghere.png'; diff --git a/packages/frontend-shared/js/embed-page.ts b/packages/frontend-shared/js/embed-page.ts index d5555a98c3..2a9b7eb478 100644 --- a/packages/frontend-shared/js/embed-page.ts +++ b/packages/frontend-shared/js/embed-page.ts @@ -6,7 +6,7 @@ //#region Embed関連の定義 /** 埋め込みの対象となるエンティティ(/embed/xxx の xxx の部分と対応させる) */ -const embeddableEntities = [ +export const embeddableEntities = [ 'notes', 'user-timeline', 'clips', diff --git a/packages/frontend-shared/js/emojilist.ts b/packages/frontend-shared/js/emojilist.ts index bde30a864f..f8bbf39177 100644 --- a/packages/frontend-shared/js/emojilist.ts +++ b/packages/frontend-shared/js/emojilist.ts @@ -9,10 +9,10 @@ export type UnicodeEmojiDef = { name: string; char: string; category: typeof unicodeEmojiCategories[number]; -} +}; // initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb -import _emojilist from './emojilist.json'; +import _emojilist from './emojilist.json' with { type: 'json' }; export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({ name: x[1] as string, diff --git a/packages/frontend-shared/js/i18n.ts b/packages/frontend-shared/js/i18n.ts index 18232691fa..d38bad45d9 100644 --- a/packages/frontend-shared/js/i18n.ts +++ b/packages/frontend-shared/js/i18n.ts @@ -2,6 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ + import type { ILocale, ParameterizedString } from '../../../locales/index.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -58,6 +59,7 @@ export class I18n { if (typeof value === 'string') { const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter); + // TODO add a flag to suppress this warning from uses of component if (parameters.length) { console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`); } diff --git a/packages/frontend-shared/js/intl-const.ts b/packages/frontend-shared/js/intl-const.ts index 33b65b6e9b..9a77cd29ef 100644 --- a/packages/frontend-shared/js/intl-const.ts +++ b/packages/frontend-shared/js/intl-const.ts @@ -20,7 +20,7 @@ try { }); } catch (err) { console.warn(err); - if (_DEV_) console.log('[Intl] Fallback to en-US'); + if (_DEV_) console.debug('[Intl] Fallback to en-US'); // Fallback to en-US _dateTimeFormat = new Intl.DateTimeFormat('en-US', { @@ -43,7 +43,7 @@ try { _numberFormat = new Intl.NumberFormat(versatileLang); } catch (err) { console.warn(err); - if (_DEV_) console.log('[Intl] Fallback to en-US'); + if (_DEV_) console.debug('[Intl] Fallback to en-US'); // Fallback to en-US _numberFormat = new Intl.NumberFormat('en-US'); diff --git a/packages/frontend-shared/js/retry-on-throttled.ts b/packages/frontend-shared/js/retry-on-throttled.ts new file mode 100644 index 0000000000..f73e19b5c7 --- /dev/null +++ b/packages/frontend-shared/js/retry-on-throttled.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: outvi and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only +*/ + +async function sleep(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(() => { + resolve(); + }, ms); + }); +} + +export async function retryOnThrottled(f: () => Promise, retryCount = 5): Promise { + let lastError; + for (let i = 0; i < Math.min(retryCount, 1); i++) { + try { + return await f(); + } catch (err: any) { + // RATE_LIMIT_EXCEEDED + if (typeof err === 'object' && err?.id === 'd5826d14-3982-4d2e-8011-b9e9f02499ef') { + lastError = err; + await sleep(err?.info?.fullResetMs ?? 1000); + } else { + throw err; + } + } + } + + throw lastError; +} diff --git a/packages/frontend-shared/js/scroll.ts b/packages/frontend-shared/js/scroll.ts index 4f2e9105c3..9057b896c6 100644 --- a/packages/frontend-shared/js/scroll.ts +++ b/packages/frontend-shared/js/scroll.ts @@ -38,7 +38,7 @@ export function getScrollPosition(el: HTMLElement | null): number { export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknown, tolerance = 1, once = false) { // とりあえず評価してみる - const firstTopVisible = isTopVisible(el); + const firstTopVisible = isHeadVisible(el); if (el.isConnected && firstTopVisible) { cb(firstTopVisible); if (once) return null; @@ -53,7 +53,7 @@ export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknow const onScroll = () => { if (!document.body.contains(el)) return; - const topVisible = isTopVisible(el, tolerance); + const topVisible = isHeadVisible(el, tolerance); if (topVisible !== prevTopVisible) { prevTopVisible = topVisible; cb(topVisible); @@ -71,7 +71,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1 const container = getScrollContainer(el); // とりあえず評価してみる - if (el.isConnected && isBottomVisible(el, tolerance, container)) { + if (el.isConnected && isTailVisible(el, tolerance, container)) { cb(); if (once) return null; } @@ -79,7 +79,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1 const containerOrWindow = container ?? window; const onScroll = () => { if (!document.body.contains(el)) return; - if (isBottomVisible(el, 1, container)) { + if (isTailVisible(el, 1, container)) { cb(); if (once) removeListener(); } @@ -93,7 +93,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1 return removeListener; } -export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) { +export function scrollInContainer(el: HTMLElement, options: ScrollToOptions | undefined) { const container = getScrollContainer(el); if (container == null) { window.scroll(options); @@ -108,7 +108,7 @@ export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) { * @param options Scroll options */ export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) { - scroll(el, { top: 0, ...options }); + scrollInContainer(el, { top: 0, ...options }); } /** @@ -132,13 +132,12 @@ export function scrollToBottom( } } -export function isTopVisible(el: HTMLElement, tolerance = 1): boolean { +export function isHeadVisible(el: HTMLElement, tolerance = 1): boolean { const scrollTop = getScrollPosition(el); - if (_DEV_) console.log(scrollTop, tolerance, scrollTop <= tolerance); return scrollTop <= tolerance; } -export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) { +export function isTailVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) { if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance; return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance; } diff --git a/packages/frontend-shared/js/worker-multi-dispatch.ts b/packages/frontend-shared/js/worker-multi-dispatch.ts index 5d393ed1ed..808b568d66 100644 --- a/packages/frontend-shared/js/worker-multi-dispatch.ts +++ b/packages/frontend-shared/js/worker-multi-dispatch.ts @@ -28,13 +28,13 @@ export class WorkerMultiDispatch { }); this.finalizationRegistry.register(this, this.symbol); - if (_DEV_) console.log('WorkerMultiDispatch: Created', this); + if (_DEV_) console.debug('WorkerMultiDispatch: Created', this); } public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: WorkerNumberGetter = this.getUseWorkerNumber) { let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length); workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length; - if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber); + if (_DEV_) console.debug('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber); this.prevWorkerNumber = workerNumber; // 不毛だがunionをoverloadに突っ込めない @@ -64,7 +64,7 @@ export class WorkerMultiDispatch { public terminate() { this.terminated = true; - if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this); + if (_DEV_) console.debug('WorkerMultiDispatch: Terminating', this); this.workers.forEach(worker => { worker.terminate(); }); diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index 6afd4e8a23..b4a5dd89f5 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -21,21 +21,20 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "22.9.0", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", - "esbuild": "0.24.0", - "eslint-plugin-vue": "9.31.0", - "nodemon": "3.1.7", - "typescript": "5.6.3", - "vue-eslint-parser": "9.4.3" + "@types/node": "22.15.2", + "@typescript-eslint/eslint-plugin": "8.31.0", + "@typescript-eslint/parser": "8.31.0", + "esbuild": "0.25.3", + "eslint-plugin-vue": "10.0.0", + "nodemon": "3.1.10", + "typescript": "5.8.3", + "vue-eslint-parser": "10.1.3" }, "files": [ "js-built" ], "dependencies": { "misskey-js": "workspace:*", - "nodemon": "3.1.7", - "vue": "3.5.12" + "vue": "3.5.13" } } diff --git a/packages/frontend-shared/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 index b3f1ab824b..1c52dac9d5 100644 --- a/packages/frontend-shared/themes/_dark.json5 +++ b/packages/frontend-shared/themes/_dark.json5 @@ -10,16 +10,11 @@ props: { accent: '#86b300', - accentDarken: ':darken<10<@accent', - accentLighten: ':lighten<10<@accent', accentedBg: ':alpha<0.15<@accent', love: '#dd2e44', focus: ':alpha<0.3<@accent', bg: '#000', - acrylicBg: ':alpha<0.5<@bg', fg: '#dadada', - fgTransparentWeak: ':alpha<0.75<@fg', - fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':lighten<3<@fg', fgOnAccent: '#fff', fgOnWhite: '#333', @@ -29,19 +24,18 @@ panelHighlight: ':lighten<3<@panel', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--MI_THEME-divider)', thread: ':lighten<12<@panel', - acrylicPanel: ':alpha<0.5<@panel', windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', shadow: 'rgba(0, 0, 0, 0.3)', header: ':alpha<0.7<@panel', navBg: '@panel', navFg: '@fg', - navHoverFg: ':lighten<17<@fg', navActive: '@accent', navIndicator: '@indicator', + pageHeaderBg: '@bg', + pageHeaderFg: '@fg', link: '#44a4c1', hashtag: '#ff9156', mention: '@accent', @@ -69,7 +63,6 @@ inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', driveFolderBg: ':alpha<0.3<@accent', - wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', badge: '#31b1ce', messageBg: '@bg', success: '#86b300', @@ -80,14 +73,6 @@ codeBoolean: '#c59eff', deckBg: '#000', htmlThemeColor: '@bg', - X3: 'rgba(255, 255, 255, 0.05)', - X4: 'rgba(255, 255, 255, 0.1)', - X5: 'rgba(255, 255, 255, 0.05)', - X6: 'rgba(255, 255, 255, 0.15)', - X7: 'rgba(255, 255, 255, 0.05)', - X11: 'rgba(0, 0, 0, 0.3)', - X12: 'rgba(255, 255, 255, 0.1)', - X13: 'rgba(255, 255, 255, 0.15)', }, codeHighlighter: { diff --git a/packages/frontend-shared/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5 index 89c632b057..c12f5be9db 100644 --- a/packages/frontend-shared/themes/_light.json5 +++ b/packages/frontend-shared/themes/_light.json5 @@ -10,16 +10,11 @@ props: { accent: '#86b300', - accentDarken: ':darken<10<@accent', - accentLighten: ':lighten<10<@accent', accentedBg: ':alpha<0.15<@accent', love: '#dd2e44', focus: ':alpha<0.3<@accent', bg: '#fff', - acrylicBg: ':alpha<0.5<@bg', fg: '#5f5f5f', - fgTransparentWeak: ':alpha<0.75<@fg', - fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':darken<3<@fg', fgOnAccent: '#fff', fgOnWhite: '#333', @@ -29,19 +24,18 @@ panelHighlight: ':darken<3<@panel', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--MI_THEME-divider)', thread: ':darken<12<@panel', - acrylicPanel: ':alpha<0.5<@panel', windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', shadow: 'rgba(0, 0, 0, 0.1)', header: ':alpha<0.7<@panel', navBg: '@panel', navFg: '@fg', - navHoverFg: ':darken<17<@fg', navActive: '@accent', navIndicator: '@indicator', + pageHeaderBg: '@bg', + pageHeaderFg: '@fg', link: '#44a4c1', hashtag: '#ff9156', mention: '@accent', @@ -69,7 +63,6 @@ inputBorder: 'rgba(0, 0, 0, 0.1)', inputBorderHover: 'rgba(0, 0, 0, 0.2)', driveFolderBg: ':alpha<0.3<@accent', - wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', badge: '#31b1ce', messageBg: '@bg', success: '#86b300', @@ -80,14 +73,6 @@ codeBoolean: '#62b70c', deckBg: ':darken<3<@bg', htmlThemeColor: '@bg', - X3: 'rgba(0, 0, 0, 0.05)', - X4: 'rgba(0, 0, 0, 0.1)', - X5: 'rgba(0, 0, 0, 0.05)', - X6: 'rgba(0, 0, 0, 0.25)', - X7: 'rgba(0, 0, 0, 0.05)', - X11: 'rgba(0, 0, 0, 0.1)', - X12: 'rgba(0, 0, 0, 0.1)', - X13: 'rgba(0, 0, 0, 0.15)', }, codeHighlighter: { diff --git a/packages/frontend-shared/themes/d-astro.json5 b/packages/frontend-shared/themes/d-astro.json5 index 4422526a33..6d34665528 100644 --- a/packages/frontend-shared/themes/d-astro.json5 +++ b/packages/frontend-shared/themes/d-astro.json5 @@ -7,9 +7,9 @@ bg: '#232125', fg: '#efdab9', link: '#78b0a0', - warn: '#ecb637', + warn: '#ffd152', badge: '#31b1ce', - error: '#ec4137', + error: '#ff6652', focus: ':alpha<0.3<@accent', navBg: '@panel', navFg: '@fg', @@ -24,23 +24,18 @@ hashtag: '#ff9156', mention: '#ffd152', modalBg: 'rgba(0, 0, 0, 0.5)', - success: '#86b300', - acrylicBg: ':alpha<0.5<@bg', + success: '#78b07f', indicator: '@accent', mentionMe: '#fb5d38', messageBg: '@bg', navActive: '@accent', infoWarnBg: '#42321c', infoWarnFg: '#ffbd3e', - navHoverFg: ':lighten<17<@fg', dateLabelFg: '@fg', inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', panelBorder: '" solid 1px var(--MI_THEME-divider)', - accentDarken: ':darken<10<@accent', - acrylicPanel: ':alpha<0.5<@panel', navIndicator: '@accent', - accentLighten: ':lighten<10<@accent', buttonGradateA: '@accent', buttonGradateB: ':hue<-20<@accent', driveFolderBg: ':alpha<0.3<@accent', @@ -51,16 +46,6 @@ fgOnWhite: '@accent', panelHighlight: ':lighten<3<@panel', scrollbarHandle: 'rgba(255, 255, 255, 0.2)', - wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', - X3: 'rgba(255, 255, 255, 0.05)', - X4: 'rgba(255, 255, 255, 0.1)', - X5: 'rgba(255, 255, 255, 0.05)', - X6: 'rgba(255, 255, 255, 0.15)', - X7: 'rgba(255, 255, 255, 0.05)', - X11: 'rgba(0, 0, 0, 0.3)', - X12: 'rgba(255, 255, 255, 0.1)', - X13: 'rgba(255, 255, 255, 0.15)', }, } diff --git a/packages/frontend-shared/themes/d-botanical.json5 b/packages/frontend-shared/themes/d-botanical.json5 index 62208d2378..5a57a14f13 100644 --- a/packages/frontend-shared/themes/d-botanical.json5 +++ b/packages/frontend-shared/themes/d-botanical.json5 @@ -14,7 +14,6 @@ fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.14)', panel: 'rgb(47, 47, 44)', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', header: ':alpha<0.7<@panel', navBg: '#363636', renote: '@accent', @@ -22,5 +21,8 @@ mentionMe: 'rgb(212, 210, 76)', hashtag: '#5bcbb0', link: '@accent', + success: '@accent', + warn: 'rgb(255, 213, 82)', + error: 'rgb(255, 105, 82)', }, } diff --git a/packages/frontend-shared/themes/d-dark.json5 b/packages/frontend-shared/themes/d-dark.json5 index ae4f7d53f5..67d49aa861 100644 --- a/packages/frontend-shared/themes/d-dark.json5 +++ b/packages/frontend-shared/themes/d-dark.json5 @@ -14,7 +14,6 @@ fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.14)', panel: '#2d2d2d', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', header: ':alpha<0.7<@panel', navBg: '#363636', renote: '@accent', diff --git a/packages/frontend-shared/themes/d-future.json5 b/packages/frontend-shared/themes/d-future.json5 index f2c1f3eb86..6a66f2eca9 100644 --- a/packages/frontend-shared/themes/d-future.json5 +++ b/packages/frontend-shared/themes/d-future.json5 @@ -15,7 +15,6 @@ fgOnWhite: '@accent', divider: 'rgba(255, 255, 255, 0.1)', panel: '#18181c', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', renote: '@accent', mention: '#f2c97d', mentionMe: '@accent', diff --git a/packages/frontend-shared/themes/d-green-lime.json5 b/packages/frontend-shared/themes/d-green-lime.json5 index ca4e688fdb..fcd6651197 100644 --- a/packages/frontend-shared/themes/d-green-lime.json5 +++ b/packages/frontend-shared/themes/d-green-lime.json5 @@ -15,7 +15,6 @@ fgOnWhite: '@accent', divider: '#e7fffb24', panel: '#192320', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', popup: '#293330', renote: '@accent', mentionMe: '#ffaa00', diff --git a/packages/frontend-shared/themes/d-green-orange.json5 b/packages/frontend-shared/themes/d-green-orange.json5 index c2539816e2..aef3897329 100644 --- a/packages/frontend-shared/themes/d-green-orange.json5 +++ b/packages/frontend-shared/themes/d-green-orange.json5 @@ -15,7 +15,6 @@ fgOnWhite: '@accent', divider: '#e7fffb24', panel: '#192320', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', popup: '#293330', renote: '@accent', mentionMe: '#b4e900', diff --git a/packages/frontend-shared/themes/d-persimmon.json5 b/packages/frontend-shared/themes/d-persimmon.json5 index 0ab6523dd7..538e3b7e70 100644 --- a/packages/frontend-shared/themes/d-persimmon.json5 +++ b/packages/frontend-shared/themes/d-persimmon.json5 @@ -22,5 +22,8 @@ mentionMe: '#de6161', hashtag: '#68bad0', link: '#a1c758', + error: '#ce5441', + warn: '#d0b868', + success: '#a1c758', }, } diff --git a/packages/frontend-shared/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5 index fb707c74c3..4f6c04b906 100644 --- a/packages/frontend-shared/themes/d-u0.json5 +++ b/packages/frontend-shared/themes/d-u0.json5 @@ -3,17 +3,9 @@ base: 'dark', name: 'Mi U0 Dark', props: { - X3: 'rgba(255, 255, 255, 0.05)', - X4: 'rgba(255, 255, 255, 0.1)', - X5: 'rgba(255, 255, 255, 0.05)', - X6: 'rgba(255, 255, 255, 0.15)', - X7: 'rgba(255, 255, 255, 0.05)', bg: '#172426', fg: '#dadada', X10: ':alpha<0.4<@accent', - X11: 'rgba(0, 0, 0, 0.3)', - X12: 'rgba(255, 255, 255, 0.1)', - X13: 'rgba(255, 255, 255, 0.15)', X14: ':alpha<0.5<@navBg', X15: ':alpha<0<@panel', X16: ':alpha<0.7<@panel', @@ -39,7 +31,6 @@ modalBg: 'rgba(0, 0, 0, 0.5)', success: '#86b300', switchBg: 'rgba(255, 255, 255, 0.15)', - acrylicBg: ':alpha<0.5<@bg', indicator: '@accent', mentionMe: '@mention', messageBg: '@bg', @@ -51,18 +42,13 @@ fgOnWhite: '@accent', infoWarnBg: '#42321c', infoWarnFg: '#ffbd3e', - navHoverFg: ':lighten<17<@fg', codeBoolean: '#c59eff', dateLabelFg: '@fg', inputBorder: 'rgba(255, 255, 255, 0.1)', panelBorder: '" solid 1px var(--MI_THEME-divider)', - accentDarken: ':darken<10<@accent', - acrylicPanel: ':alpha<0.5<@panel', navIndicator: '@indicator', - accentLighten: ':lighten<10<@accent', driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', - fgTransparent: ':alpha<0.5<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', buttonGradateA: '@accent', @@ -71,9 +57,6 @@ panelHighlight: ':lighten<3<@panel', scrollbarHandle: 'rgba(255, 255, 255, 0.2)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', - wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', - fgTransparentWeak: ':alpha<0.75<@fg', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', deckBg: '#142022', }, diff --git a/packages/frontend-shared/themes/l-botanical.json5 b/packages/frontend-shared/themes/l-botanical.json5 index 17e9ca246f..2fbae4fbae 100644 --- a/packages/frontend-shared/themes/l-botanical.json5 +++ b/packages/frontend-shared/themes/l-botanical.json5 @@ -13,18 +13,17 @@ fgHighlighted: '#6bc9a0', fgOnWhite: '@accent', divider: '#cfcfcf', - panel: '@X14', + panel: '#ebe7e5', panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', header: ':alpha<0.7<@panel', - navBg: '@X14', + navBg: '#ebe7e5', renote: '#229e92', mention: '#da6d35', mentionMe: '#d44c4c', hashtag: '#4cb8d4', link: '@accent', buttonGradateB: ':hue<-70<@accent', - success: '#86b300', - X14: '#ebe7e5' + success: '@accent', + error: '#da5635', }, } diff --git a/packages/frontend-shared/themes/l-coffee.json5 b/packages/frontend-shared/themes/l-coffee.json5 index b64cc73583..df3a12a37b 100644 --- a/packages/frontend-shared/themes/l-coffee.json5 +++ b/packages/frontend-shared/themes/l-coffee.json5 @@ -18,5 +18,8 @@ mention: '@accent', mentionMe: 'rgb(170, 149, 98)', hashtag: '@accent', + error: '#db9184', + warn: '#dbc184', + success: '#a3c975', }, } diff --git a/packages/frontend-shared/themes/l-light.json5 b/packages/frontend-shared/themes/l-light.json5 index 63c2e6d278..55f2d2f004 100644 --- a/packages/frontend-shared/themes/l-light.json5 +++ b/packages/frontend-shared/themes/l-light.json5 @@ -15,7 +15,6 @@ header: ':alpha<0.7<@panel', navBg: '#fff', panel: '#fff', - panelHeaderDivider: '@divider', mentionMe: 'rgb(0, 179, 70)', }, } diff --git a/packages/frontend-shared/themes/l-rainy.json5 b/packages/frontend-shared/themes/l-rainy.json5 index e7d1d5af00..d7c31bda8d 100644 --- a/packages/frontend-shared/themes/l-rainy.json5 +++ b/packages/frontend-shared/themes/l-rainy.json5 @@ -13,7 +13,6 @@ fgOnWhite: '@accent', panel: '#fff', divider: 'rgb(230 233 234)', - panelHeaderDivider: '@divider', renote: '@accent', link: '@accent', mention: '@accent', diff --git a/packages/frontend-shared/themes/l-u0.json5 b/packages/frontend-shared/themes/l-u0.json5 index 7062e7fe5b..35241986df 100644 --- a/packages/frontend-shared/themes/l-u0.json5 +++ b/packages/frontend-shared/themes/l-u0.json5 @@ -3,17 +3,9 @@ base: 'light', name: 'Mi U0 Light', props: { - X3: 'rgba(255, 255, 255, 0.05)', - X4: 'rgba(255, 255, 255, 0.1)', - X5: 'rgba(255, 255, 255, 0.05)', - X6: 'rgba(255, 255, 255, 0.15)', - X7: 'rgba(255, 255, 255, 0.05)', bg: '#e7e7eb', fg: '#5f5f5f', X10: ':alpha<0.4<@accent', - X11: 'rgba(0, 0, 0, 0.3)', - X12: 'rgba(255, 255, 255, 0.1)', - X13: 'rgba(255, 255, 255, 0.15)', X14: ':alpha<0.5<@navBg', X15: ':alpha<0<@panel', X16: ':alpha<0.7<@panel', @@ -40,7 +32,6 @@ success: '#86b300', buttonBg: '#0000000d', switchBg: 'rgba(255, 255, 255, 0.15)', - acrylicBg: ':alpha<0.5<@bg', indicator: '@accent', mentionMe: '@mention', messageBg: '@bg', @@ -52,19 +43,14 @@ fgOnWhite: '@accent', infoWarnBg: '#42321c', infoWarnFg: '#ffbd3e', - navHoverFg: ':lighten<17<@fg', codeBoolean: '#c59eff', dateLabelFg: '@fg', inputBorder: 'rgba(255, 255, 255, 0.1)', panelBorder: '" solid 1px var(--MI_THEME-divider)', - accentDarken: ':darken<10<@accent', - acrylicPanel: ':alpha<0.5<@panel', navIndicator: '@indicator', - accentLighten: ':lighten<10<@accent', buttonHoverBg: '#0000001a', driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', - fgTransparent: ':alpha<0.5<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', buttonGradateA: '@accent', @@ -73,9 +59,6 @@ panelHighlight: ':lighten<3<@panel', scrollbarHandle: '#74747433', inputBorderHover: 'rgba(255, 255, 255, 0.2)', - wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', - fgTransparentWeak: ':alpha<0.75<@fg', - panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', }, } diff --git a/packages/frontend-shared/themes/l-vivid.json5 b/packages/frontend-shared/themes/l-vivid.json5 index 39768d4ac6..5ad8d60728 100644 --- a/packages/frontend-shared/themes/l-vivid.json5 +++ b/packages/frontend-shared/themes/l-vivid.json5 @@ -28,42 +28,25 @@ mention: '@accent', modalBg: 'rgba(0, 0, 0, 0.3)', success: '#86b300', - acrylicBg: ':alpha<0.5<@bg', indicator: '@accent', mentionMe: '@mention', messageBg: '@bg', navActive: '@accent', infoWarnBg: '#fff0db', infoWarnFg: '#8f6e31', - navHoverFg: ':darken<17<@fg', dateLabelFg: '@fg', inputBorder: 'rgba(0, 0, 0, 0.1)', inputBorderHover: 'rgba(0, 0, 0, 0.2)', panelBorder: '" solid 1px var(--MI_THEME-divider)', - accentDarken: ':darken<10<@accent', - acrylicPanel: ':alpha<0.5<@panel', navIndicator: '@accent', - accentLighten: ':lighten<10<@accent', driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':darken<3<@fg', - fgTransparent: ':alpha<0.5<@fg', fgOnWhite: '@accent', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', htmlThemeColor: '@bg', panelHighlight: ':darken<3<@panel', scrollbarHandle: 'rgba(0, 0, 0, 0.2)', - wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', - fgTransparentWeak: ':alpha<0.75<@fg', - panelHeaderDivider: '@divider', scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', - X3: 'rgba(0, 0, 0, 0.05)', - X4: 'rgba(0, 0, 0, 0.1)', - X5: 'rgba(0, 0, 0, 0.05)', - X6: 'rgba(0, 0, 0, 0.25)', - X7: 'rgba(0, 0, 0, 0.05)', - X11: 'rgba(0, 0, 0, 0.1)', - X12: 'rgba(0, 0, 0, 0.1)', - X13: 'rgba(0, 0, 0, 0.15)', }, } diff --git a/packages/frontend-shared/tsconfig.json b/packages/frontend-shared/tsconfig.json index 48228d4e48..0512b50caf 100644 --- a/packages/frontend-shared/tsconfig.json +++ b/packages/frontend-shared/tsconfig.json @@ -16,7 +16,9 @@ "experimentalDecorators": true, "noImplicitReturns": true, "esModuleInterop": true, + "verbatimModuleSyntax": true, "skipLibCheck": true, + "incremental": true, "baseUrl": ".", "paths": { "@/*": ["./*"], diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts index 1299910499..c7e0048818 100644 --- a/packages/frontend/.storybook/changes.ts +++ b/packages/frontend/.storybook/changes.ts @@ -55,7 +55,7 @@ await fs.readFile( '../../locales/ja-JP.yml', 'assets/**', 'public/**', - '../../pnpm-lock.yaml', + 'package.json', ]).length ) { return; diff --git a/packages/frontend/.storybook/charts.ts b/packages/frontend/.storybook/charts.ts index 5015012a82..31bb9e51c5 100644 --- a/packages/frontend/.storybook/charts.ts +++ b/packages/frontend/.storybook/charts.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { DefaultBodyType, HttpResponse, HttpResponseResolver, JsonBodyType, PathParams, http } from 'msw'; +import { HttpResponse, http } from 'msw'; +import type { DefaultBodyType, HttpResponseResolver, JsonBodyType, PathParams } from 'msw'; import seedrandom from 'seedrandom'; import { action } from '@storybook/addon-actions'; diff --git a/packages/frontend/.storybook/fake-utils.ts b/packages/frontend/.storybook/fake-utils.ts index c777cbbe72..44e2263ca0 100644 --- a/packages/frontend/.storybook/fake-utils.ts +++ b/packages/frontend/.storybook/fake-utils.ts @@ -131,7 +131,7 @@ export function imageDataUrl(options?: { alpha?: number, } }, seed?: string): string { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = options?.size?.width ?? 100; canvas.height = options?.size?.height ?? 100; diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 377d26d6a3..7288dabb60 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -43,6 +43,41 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl: }; } +export function chatMessage(room = false, id = 'somechatmessageid', text = 'Hello!'): entities.ChatMessage { + const fromUser = userLite(); + const toRoom = chatRoom(); + const toUser = userLite('touserid'); + return { + id, + createdAt: '2016-12-28T22:49:51.000Z', + fromUserId: fromUser.id, + fromUser, + text, + isRead: false, + reactions: [], + ...room ? { + toRoomId: toRoom.id, + toRoom, + } : { + toUserId: toUser.id, + toUser, + }, + }; +} + +export function chatRoom(id = 'somechatroomid', name = 'Some Chat Room'): entities.ChatRoom { + const owner = userLite('someownerid'); + return { + id, + createdAt: '2016-12-28T22:49:51.000Z', + ownerId: owner.id, + owner, + name, + description: 'A chat room for testing', + isMuted: false, + }; +} + export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip { return { id, diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 8830523810..89d4214141 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -17,8 +17,52 @@ interface SatisfiesExpression extends estree.BaseExpression { reference: estree.Identifier; } +interface ImportDeclaration extends estree.ImportDeclaration { + kind?: 'type'; +} + const generator = { ...GENERATOR, + ImportDeclaration(node: ImportDeclaration, state: State) { + state.write('import '); + if (node.kind === 'type') state.write('type '); + const { specifiers } = node; + if (specifiers.length > 0) { + let i = 0; + for (; i < specifiers.length; i++) { + if (i > 0) { + state.write(', '); + } + const specifier = specifiers[i]!; + if (specifier.type === 'ImportDefaultSpecifier') { + state.write(specifier.local.name, specifier); + } else if (specifier.type === 'ImportNamespaceSpecifier') { + state.write(`* as ${specifier.local.name}`, specifier); + } else { + break; + } + } + if (i < specifiers.length) { + state.write('{'); + for (; i < specifiers.length; i++) { + const specifier = specifiers[i]! as estree.ImportSpecifier; + const { name } = specifier.imported as estree.Identifier; + state.write(name, specifier); + if (name !== specifier.local.name) { + state.write(` as ${specifier.local.name}`); + } + if (i < specifiers.length - 1) { + state.write(', '); + } + } + state.write('}'); + } + state.write(' from '); + } + this.Literal(node.source, state); + + state.write(';'); + }, SatisfiesExpression(node: SatisfiesExpression, state: State) { switch (node.expression.type) { case 'ArrowFunctionExpression': { @@ -62,7 +106,7 @@ type ToKebab = T extends readonly [ : T extends readonly [ infer XH extends string, ...infer XR extends readonly string[] - ] + ] ? `${XH}${XR extends readonly string[] ? `-${ToKebab}` : ''}` : ''; @@ -132,7 +176,7 @@ function toStories(component: string): Promise { kind={'init' as const} shorthand /> as estree.Property, - ] + ] : []), ]} /> as estree.ObjectExpression; @@ -155,7 +199,8 @@ function toStories(component: string): Promise { /> as estree.ImportSpecifier, ]), ]} - /> as estree.ImportDeclaration, + kind={'type'} + /> as ImportDeclaration, ...(hasMsw ? [ { local={ as estree.Identifier} /> as estree.ImportNamespaceSpecifier, ]} - /> as estree.ImportDeclaration, - ] + /> as ImportDeclaration, + ] : []), ...(hasImplStories ? [] @@ -176,8 +221,8 @@ function toStories(component: string): Promise { specifiers={[ as estree.ImportDefaultSpecifier, ]} - /> as estree.ImportDeclaration, - ]), + /> as ImportDeclaration, + ]), ...(hasMetaStories ? [ { local={ as estree.Identifier} /> as estree.ImportNamespaceSpecifier, ]} - /> as estree.ImportDeclaration, + /> as ImportDeclaration, ] : []), { glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkUserSetupDialog.vue'), glob('src/components/MkUserSetupDialog.*.vue'), + glob('src/components/MkImgPreviewDialog.vue'), glob('src/components/MkInstanceCardMini.vue'), glob('src/components/MkInviteCode.vue'), glob('src/components/MkTagItem.vue'), diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts index 9f318cf449..c1119c2523 100644 --- a/packages/frontend/.storybook/main.ts +++ b/packages/frontend/.storybook/main.ts @@ -39,6 +39,10 @@ const config = { if (~replacePluginForIsChromatic) { config.plugins?.splice(replacePluginForIsChromatic, 1); } + + //pluginsからcreateSearchIndexを削除、複数あるかもしれないので全て削除 + config.plugins = config.plugins?.filter((plugin: Plugin) => plugin && plugin.name !== 'createSearchIndex') ?? []; + return mergeConfig(config, { plugins: [ { diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html index ae42fd49bc..2431a71ddc 100644 --- a/packages/frontend/.storybook/preview-head.html +++ b/packages/frontend/.storybook/preview-head.html @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index 9df957f3ec..b62096bbe9 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index a634a748e9..61297fdc76 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
@@ -25,12 +25,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.send }}
- +
- @@ -407,7 +456,7 @@ onBeforeUnmount(() => { text-overflow: ellipsis; &:hover { - background: var(--MI_THEME-X3); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &[data-selected='true'] { @@ -416,7 +465,7 @@ onBeforeUnmount(() => { } &:active { - background: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); color: #fff !important; } } diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts index d2a4a9f03b..6e20294438 100644 --- a/packages/frontend/src/components/MkAvatars.stories.impl.ts +++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 8236d0ddb9..1c44ed60d8 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkClickerGame.stories.impl.ts b/packages/frontend/src/components/MkClickerGame.stories.impl.ts index 36313f965d..6e1eb13d61 100644 --- a/packages/frontend/src/components/MkClickerGame.stories.impl.ts +++ b/packages/frontend/src/components/MkClickerGame.stories.impl.ts @@ -2,18 +2,16 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; + import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkClickerGame from './MkClickerGame.vue'; +import type { StoryObj } from '@storybook/vue3'; function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => window.setTimeout(resolve, ms)); } export const Default = { diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 9a0a9fba05..775964af50 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -23,9 +23,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import * as os from '@/os.js'; import { useInterval } from '@@/js/use-interval.js'; -import * as game from '@/scripts/clicker-game.js'; +import * as game from '@/utility/clicker-game.js'; import number from '@/filters/number.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { claimAchievement } from '@/utility/achievements.js'; const saveData = game.saveData; const cookies = computed(() => saveData.value?.cookies); diff --git a/packages/frontend/src/components/MkClipPreview.stories.impl.ts b/packages/frontend/src/components/MkClipPreview.stories.impl.ts index 62503fb98a..496dc09eed 100644 --- a/packages/frontend/src/components/MkClipPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkClipPreview.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { clip } from '../../.storybook/fakes.js'; import MkClipPreview from './MkClipPreview.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 5b09ec90dd..2154c08ab3 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import number from '@/filters/number.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index e253b1b55f..36c08a8c64 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, watch } from 'vue'; import { bundledLanguagesInfo } from 'shiki/langs'; import type { BundledLanguage } from 'shiki/langs'; -import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js'; -import { defaultStore } from '@/store.js'; +import { getHighlighter, getTheme } from '@/utility/code-highlighter.js'; +import { store } from '@/store.js'; const props = defineProps<{ code: string; @@ -22,7 +22,7 @@ const props = defineProps<{ }>(); const highlighter = await getHighlighter(); -const darkMode = defaultStore.reactiveState.darkMode; +const darkMode = store.r.darkMode; const codeLang = ref('js'); const [lightThemeName, darkThemeName] = await Promise.all([ @@ -52,7 +52,7 @@ async function fetchLanguage(to: string): Promise { return bundle.id === language || bundle.aliases?.includes(language); }); if (bundles.length > 0) { - if (_DEV_) console.log(`Loading language: ${language}`); + if (_DEV_) console.debug(`Loading language: ${language}`); await highlighter.loadLanguage(bundles[0].import); codeLang.value = language; } else { @@ -93,7 +93,7 @@ watch(() => props.lang, (to) => { .codeBlockRoot :global(.shiki) { padding: 1em; - margin: .5em 0; + margin: 0; overflow: auto; border-radius: var(--MI-radius-sm); border: 1px solid var(--MI_THEME-divider); diff --git a/packages/frontend/src/components/MkCode.stories.impl.ts b/packages/frontend/src/components/MkCode.stories.impl.ts index b7e53e8e35..fae9d459fb 100644 --- a/packages/frontend/src/components/MkCode.stories.impl.ts +++ b/packages/frontend/src/components/MkCode.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCode from './MkCode.vue'; const code = `for (let i, 100) { <: if (i % 15 == 0) "FizzBuzz" diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a46b220101..d79ecc7302 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -10,10 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only - -
{{ code }}
+ +
{{ code }}
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index c7af75e2e7..f33896b7da 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -27,11 +27,11 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -209,11 +247,11 @@ const big = isTouchUsing; const isNestingMenu = inject('isNestingMenu', false); -const itemsEl = shallowRef(); +const itemsEl = useTemplateRef('itemsEl'); const items2 = ref(); -const child = shallowRef>(); +const child = useTemplateRef('child'); const keymap = { 'up|k|shift+tab': { @@ -254,7 +292,7 @@ watch(() => props.items, () => { }); const childMenu = ref(); -const childTarget = shallowRef(); +const childTarget = shallowRef(); function closeChild() { childMenu.value = null; @@ -355,10 +393,10 @@ function switchItem(item: MenuSwitch & { ref: any }) { function focusUp() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1); const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -367,10 +405,10 @@ function focusUp() { function focusDown() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0; const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -397,9 +435,9 @@ const onGlobalMousedown = (ev: MouseEvent) => { const setupHandlers = () => { if (!isNestingMenu) { - document.addEventListener('focusin', onGlobalFocusin, { passive: true }); + window.document.addEventListener('focusin', onGlobalFocusin, { passive: true }); } - document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); + window.document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); }; let disposed = false; @@ -407,9 +445,9 @@ let disposed = false; const disposeHandlers = () => { disposed = true; if (!isNestingMenu) { - document.removeEventListener('focusin', onGlobalFocusin); + window.document.removeEventListener('focusin', onGlobalFocusin); } - document.removeEventListener('mousedown', onGlobalMousedown); + window.document.removeEventListener('mousedown', onGlobalMousedown); }; onMounted(() => { @@ -435,6 +473,12 @@ onBeforeUnmount(() => { } } + &:not(.asDrawer):not(.widthSpecified) { + > .menu { + max-width: 400px; + } + } + &.big:not(.asDrawer) { > .menu { min-width: 230px; @@ -558,11 +602,11 @@ onBeforeUnmount(() => { } &.danger { - --menuFg: #ff2a2a; + --menuFg: var(--MI_THEME-error); --menuHoverFg: #fff; - --menuHoverBg: #ff4242; + --menuHoverBg: var(--MI_THEME-error); --menuActiveFg: #fff; - --menuActiveBg: #d42e2e; + --menuActiveBg: hsl(from var(--MI_THEME-error) h s calc(l - 10)); } &.radio { @@ -575,12 +619,6 @@ onBeforeUnmount(() => { --menuActiveBg: var(--MI_THEME-accentedBg); } - &.label { - pointer-events: none; - font-size: 0.7em; - padding-bottom: 4px; - } - &.pending { pointer-events: none; opacity: 0.7; @@ -604,10 +642,19 @@ onBeforeUnmount(() => { .item_content_text { max-width: calc(100vw - 4rem); +} + +.item_content_text_title { text-overflow: ellipsis; overflow: hidden; } +.item_content_text_caption { + text-wrap: auto; + font-size: 85%; + opacity: 0.7; +} + .switchButton { margin-left: -2px; --height: 1.35em; @@ -641,6 +688,19 @@ onBeforeUnmount(() => { font-size: 12px; } +.label { + position: relative; + padding: 6px 16px; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.7em; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; + pointer-events: none; +} + .divider { margin: 8px 0; border-top: solid 0.5px var(--MI_THEME-divider); diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 7ea585ecc2..98bd471438 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -48,7 +48,7 @@ const polygonPoints = ref(''); const headX = ref(null); const headY = ref(null); const clock = ref(null); -const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent')); +const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent')); const color = accent.toRgbString(); function draw(): void { diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index a446dad0ab..3bcf835ec9 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -42,14 +42,15 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index a910151e42..220ca04d47 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -27,6 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_login]: notification.type === 'login', + [$style.t_createToken]: notification.type === 'createToken', + [$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, [$style.t_pollEnded]: notification.type === 'edited', [$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed', @@ -44,6 +46,8 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkNumber.vue b/packages/frontend/src/components/MkNumber.vue index a278205b61..7c2393bf5c 100644 --- a/packages/frontend/src/components/MkNumber.vue +++ b/packages/frontend/src/components/MkNumber.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index f6218de4c8..72f3ced088 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -33,15 +33,15 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index ca227d649a..a650365a28 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- - +
{{ i18n.ts.notSpecifiedMentionWarning }} - -
+
-
{{ maxCwLength - cwLength }}
+
{{ maxCwTextLength - cwTextLength }}
@@ -91,7 +92,6 @@ SPDX-License-Identifier: AGPL-3.0-only -
@@ -106,43 +106,50 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index 6efd99d14b..d8dfbd1655 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -43,7 +43,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os.js'; import * as config from '@@/js/config.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const text = ref(''); const flag = ref(true); diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 4fb4c6fe56..22ae563d13 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -16,17 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- -
+ + diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue index 873b276b3d..cb50df1743 100644 --- a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue +++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
{{ i18n.ts.import }} @@ -125,7 +125,7 @@ async function done() { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 64b573c4d3..1ab2397337 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -13,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkSwiper.vue similarity index 87% rename from packages/frontend/src/components/MkHorizontalSwipe.vue rename to packages/frontend/src/components/MkSwiper.vue index 196c962a06..c0d6dc463c 100644 --- a/packages/frontend/src/components/MkHorizontalSwipe.vue +++ b/packages/frontend/src/components/MkSwiper.vue @@ -12,39 +12,39 @@ SPDX-License-Identifier: AGPL-3.0-only @touchend.passive="touchEnd" > - - - +
+ +
+ + + + diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 87aa046963..9d541c8acb 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -15,18 +15,18 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 7a9abab62e..61b34b561d 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -5,29 +5,50 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkTl.vue b/packages/frontend/src/components/MkTl.vue new file mode 100644 index 0000000000..95cc4d2a2a --- /dev/null +++ b/packages/frontend/src/components/MkTl.vue @@ -0,0 +1,173 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index 38b537cbc9..571835432e 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 1aebf487bb..73ce393113 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -5,26 +5,31 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/global/PageWithAnimBg.vue b/packages/frontend/src/components/global/PageWithAnimBg.vue new file mode 100644 index 0000000000..7106ae20cd --- /dev/null +++ b/packages/frontend/src/components/global/PageWithAnimBg.vue @@ -0,0 +1,29 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue new file mode 100644 index 0000000000..485ea687de --- /dev/null +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -0,0 +1,74 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 38bdfc52d4..27f7b18559 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -4,98 +4,68 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/components/global/SearchIcon.vue b/packages/frontend/src/components/global/SearchIcon.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchIcon.vue @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/SearchKeyword.vue b/packages/frontend/src/components/global/SearchKeyword.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchKeyword.vue @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/SearchLabel.vue b/packages/frontend/src/components/global/SearchLabel.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchLabel.vue @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue new file mode 100644 index 0000000000..ded1f9a28b --- /dev/null +++ b/packages/frontend/src/components/global/SearchMarker.vue @@ -0,0 +1,117 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/SkLazy.vue b/packages/frontend/src/components/global/SkLazy.vue deleted file mode 100644 index 40add97db7..0000000000 --- a/packages/frontend/src/components/global/SkLazy.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue new file mode 100644 index 0000000000..38a5c1ba23 --- /dev/null +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -0,0 +1,244 @@ + + + + + + + diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index e473b7c1af..55de0df690 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -39,10 +39,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ cell.value }}
-
+
diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index e5b1eff294..3891380dd0 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -7,26 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 4762ef3f97..b7375b0faf 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -9,6 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only + + @@ -21,6 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + @@ -55,12 +65,44 @@ SPDX-License-Identifier: AGPL-3.0-only - + + + + + + + + + + +
+ + {{ i18n.ts._role.remoteDataWarning }} +
@@ -71,8 +113,9 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; import { rolesCache } from '@/cache.js'; +import MkSwitch from '@/components/MkSwitch.vue'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -102,6 +145,7 @@ watch(v, () => { const type = computed({ get: () => v.value.type, set: (t) => { + // TODO there's a bug here: switching types leaves extra properties in the JSON if (t === 'and') v.value.values = []; if (t === 'or') v.value.values = []; if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' }; @@ -112,8 +156,20 @@ const type = computed({ if (t === 'followersMoreThanOrEq') v.value.value = 10; if (t === 'followingLessThanOrEq') v.value.value = 10; if (t === 'followingMoreThanOrEq') v.value.value = 10; + if (t === 'localFollowersLessThanOrEq') v.value.value = 10; + if (t === 'localFollowersMoreThanOrEq') v.value.value = 10; + if (t === 'localFollowingLessThanOrEq') v.value.value = 10; + if (t === 'localFollowingMoreThanOrEq') v.value.value = 10; + if (t === 'remoteFollowersLessThanOrEq') v.value.value = 10; + if (t === 'remoteFollowersMoreThanOrEq') v.value.value = 10; + if (t === 'remoteFollowingLessThanOrEq') v.value.value = 10; + if (t === 'remoteFollowingMoreThanOrEq') v.value.value = 10; if (t === 'notesLessThanOrEq') v.value.value = 10; if (t === 'notesMoreThanOrEq') v.value.value = 10; + if (t === 'isFromInstance') { + v.value.host = ''; + v.value.subdomains = true; + } v.value.type = t; }, }); @@ -163,4 +219,14 @@ function removeSelf() { border-color: var(--MI_THEME-accent); } } + +.warningBanner { + color: var(--MI_THEME-warn); + width: 100%; + padding: 0 6px; + + > i { + margin-right: 4px; + } +} diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue deleted file mode 100644 index b0651150a6..0000000000 --- a/packages/frontend/src/pages/admin/_header_.vue +++ /dev/null @@ -1,296 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue index eef24afd32..b69c818b48 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ mode === 'create' ? i18n.ts._abuseReport._notificationRecipient.createRecipient : i18n.ts._abuseReport._notificationRecipient.modifyRecipient }}
- +
@@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
{{ i18n.ts.ok }} @@ -71,15 +71,16 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue deleted file mode 100644 index 6e7e7e53e3..0000000000 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - - - diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue similarity index 77% rename from packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue rename to packages/frontend/src/pages/admin/custom-emojis-manager.register.vue index cc8b625cd5..e8e944df32 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue @@ -4,72 +4,69 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -78,30 +75,32 @@ SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable @typescript-eslint/no-non-null-assertion */ import * as Misskey from 'misskey-js'; import { onMounted, ref, useCssModule } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; +import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import type { DroppedFile } from '@/utility/file-drop.js'; +import type { GridSetting } from '@/components/grid/grid.js'; +import type { GridRow } from '@/components/grid/row.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { emptyStrToEmptyArray, emptyStrToNull, - RequestLogItem, roleIdsParser, } from '@/pages/admin/custom-emojis-manager.impl.js'; import MkGrid from '@/components/grid/MkGrid.vue'; import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { defaultStore } from '@/store.js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { validators } from '@/components/grid/cell-validators.js'; -import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js'; -import { uploadFile } from '@/scripts/upload.js'; -import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; -import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js'; +import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js'; +import { uploadFile } from '@/utility/upload.js'; +import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; -import { GridSetting } from '@/components/grid/grid.js'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; -import { GridRow } from '@/components/grid/row.js'; + +import { prefer } from '@/preferences.js'; const MAXIMUM_EMOJI_REGISTER_COUNT = 100; @@ -122,7 +121,7 @@ type GridItem = { localOnly: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[]; type: string | null; -} +}; function setupGrid(): GridSetting { const $style = useCssModule(); @@ -242,8 +241,7 @@ function setupGrid(): GridSetting { const uploadFolders = ref([]); const gridItems = ref([]); -const selectedFolderId = ref(defaultStore.state.uploadFolder); -const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading); +const selectedFolderId = ref(prefer.s.uploadFolder); const directoryToCategory = ref(false); const registerButtonDisabled = ref(false); const requestLogs = ref([]); @@ -336,7 +334,7 @@ async function onDrop(ev: DragEvent) { it.file, selectedFolderId.value, it.file.name.replace(/\.[^.]+$/, ''), - keepOriginalUploading.value, + true, ), }), ), @@ -371,7 +369,7 @@ async function onFileSelectClicked() { true, { uploadFolder: selectedFolderId.value, - keepOriginal: keepOriginalUploading.value, + keepOriginal: true, // 拡張子は消す nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), }, @@ -411,7 +409,7 @@ function fromDriveFile(it: Misskey.entities.DriveFile): GridItem { return { fileId: it.id, url: it.url, - name: it.name.replace(/(\.[a-zA-Z0-9]+)+$/, ''), + name: it.name.replace(/(\.[a-zA-Z0-9]+)+$/, '').replaceAll('-', '_').replaceAll(' ', '_'), host: '', category: '', aliases: '', diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue index eecf8d7390..2fd7e331a2 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue @@ -142,27 +142,23 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index 188678c183..16bed50002 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -4,72 +4,68 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/job-queue.job.vue b/packages/frontend/src/pages/admin/job-queue.job.vue new file mode 100644 index 0000000000..71efab0272 --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.job.vue @@ -0,0 +1,280 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue new file mode 100644 index 0000000000..155277c976 --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -0,0 +1,371 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 27cbfda078..2213890d14 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -4,176 +4,190 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue index 45f0fff107..b53667e98c 100644 --- a/packages/frontend/src/pages/admin/system-webhook.item.vue +++ b/packages/frontend/src/pages/admin/system-webhook.item.vue @@ -75,6 +75,6 @@ function onDeleteClick() { margin-right: 0.75em; flex-shrink: 0; text-align: center; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue index c59abda24a..d5402f608c 100644 --- a/packages/frontend/src/pages/admin/system-webhook.vue +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -4,12 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/chat/XRoom.vue b/packages/frontend/src/pages/chat/XRoom.vue new file mode 100644 index 0000000000..b063a0cdd1 --- /dev/null +++ b/packages/frontend/src/pages/chat/XRoom.vue @@ -0,0 +1,41 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue new file mode 100644 index 0000000000..a0853fb0c9 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -0,0 +1,131 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue new file mode 100644 index 0000000000..82b22ea9dd --- /dev/null +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -0,0 +1,93 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue new file mode 100644 index 0000000000..f9fd6bfd55 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue @@ -0,0 +1,45 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue new file mode 100644 index 0000000000..ce7da15563 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue @@ -0,0 +1,46 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue new file mode 100644 index 0000000000..652ab04be6 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.vue @@ -0,0 +1,57 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue new file mode 100644 index 0000000000..a04ec7fd87 --- /dev/null +++ b/packages/frontend/src/pages/chat/message.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue new file mode 100644 index 0000000000..9389b16ce7 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -0,0 +1,350 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue new file mode 100644 index 0000000000..2f091388a0 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.info.vue @@ -0,0 +1,99 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue new file mode 100644 index 0000000000..5a574068cb --- /dev/null +++ b/packages/frontend/src/pages/chat/room.members.vue @@ -0,0 +1,98 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue new file mode 100644 index 0000000000..20b6e22a46 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue new file mode 100644 index 0000000000..6505e172dd --- /dev/null +++ b/packages/frontend/src/pages/chat/room.vue @@ -0,0 +1,518 @@ + + + + + + + diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue index 9e9b5e8688..d418a78ee5 100644 --- a/packages/frontend/src/pages/clicker.vue +++ b/packages/frontend/src/pages/clicker.vue @@ -4,19 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index 2b85489706..f3365fcedf 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -4,39 +4,36 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index 3416ee6cfc..0342d5c604 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -4,18 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only -->
-
- +
+ diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index 10ea3717ab..a447572cc0 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -14,17 +14,17 @@ import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import GameSetting from './game.setting.vue'; import GameBoard from './game.board.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { useStream } from '@/stream.js'; -import { signinRequired } from '@/account.js'; -import { useRouter } from '@/router/supplier.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; import * as os from '@/os.js'; import { url } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@@/js/use-interval.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const router = useRouter(); @@ -114,7 +114,7 @@ onUnmounted(() => { } }); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Reversi', icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue index d608a2411c..f3252402d7 100644 --- a/packages/frontend/src/pages/reversi/index.vue +++ b/packages/frontend/src/pages/reversi/index.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/search.stories.impl.ts b/packages/frontend/src/pages/search.stories.impl.ts index 0110a7ab8e..27271615c2 100644 --- a/packages/frontend/src/pages/search.stories.impl.ts +++ b/packages/frontend/src/pages/search.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import search_ from './search.vue'; import { userDetailed } from '@/../.storybook/fakes.js'; diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 8d0899a30c..0601fb0902 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
@@ -36,8 +36,8 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useRouter } from '@/router/supplier.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useRouter } from '@/router.js'; const props = withDefaults(defineProps<{ query?: string, @@ -49,14 +49,16 @@ const props = withDefaults(defineProps<{ const router = useRouter(); -const key = ref(''); +const key = ref(0); +const userPagination = ref>(); + const searchQuery = ref(toRef(props, 'query').value); const searchOrigin = ref(toRef(props, 'origin').value); -const userPagination = ref(); async function search() { const query = searchQuery.value.toString().trim(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (query == null || query === '') return; //#region AP lookup @@ -76,6 +78,7 @@ async function search() { if (res.type === 'User') { router.push(`/@${res.object.username}@${res.object.host}`); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (res.type === 'Note') { router.push(`/notes/${res.object.id}`); } @@ -85,17 +88,6 @@ async function search() { } //#endregion - if (query.length > 1 && !query.includes(' ') && query.startsWith('#')) { - const confirm = await os.confirm({ - type: 'info', - text: i18n.ts.openTagPageConfirm, - }); - if (!confirm.canceled) { - router.push(`/user-tags/${encodeURIComponent(query.substring(1))}`); - return; - } - } - if (query.match(/^@[a-z0-9_.-]+@[a-z0-9_.-]+$/i)) { const confirm = await os.confirm({ type: 'info', @@ -107,6 +99,17 @@ async function search() { } } + if (query.length > 1 && !query.includes(' ') && query.startsWith('#')) { + const confirm = await os.confirm({ + type: 'info', + text: i18n.ts.openTagPageConfirm, + }); + if (!confirm.canceled) { + router.push(`/user-tags/${encodeURIComponent(query.substring(1))}`); + return; + } + } + userPagination.value = { endpoint: 'users/search', limit: 10, @@ -116,6 +119,6 @@ async function search() { }, }; - key.value = query; + key.value++; } diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index 38d7548fa8..b6d21a4616 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -4,33 +4,28 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index c2588736b3..2fd0a021da 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -4,80 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 6515503505..c72179b9a1 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -57,9 +57,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import FormPagination from '@/components/MkPagination.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -86,7 +86,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.installedApps, icon: 'ti ti-plug', })); diff --git a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue index c71595380e..d3bec4a19e 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue @@ -16,9 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue index cf05e75acc..5b9b0d897a 100644 --- a/packages/frontend/src/pages/settings/custom-css.vue +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -18,34 +18,19 @@ import { ref, watch, computed } from 'vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import FormInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; +import { prefer } from '@/preferences.js'; -const localCustomCss = ref(miLocalStorage.getItem('customCss') ?? ''); - -async function apply() { - miLocalStorage.setItem('customCss', localCustomCss.value); - - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -} - -watch(localCustomCss, async () => { - await apply(); -}); +const localCustomCss = prefer.model('customCss'); const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.customCss, icon: 'ti ti-code', })); diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index e574ec7dc0..39055268d4 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -4,39 +4,140 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue new file mode 100644 index 0000000000..e1a1c39f03 --- /dev/null +++ b/packages/frontend/src/pages/settings/emoji-palette.vue @@ -0,0 +1,284 @@ + + + + + + + diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue deleted file mode 100644 index 7ddc1eab71..0000000000 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ /dev/null @@ -1,316 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue deleted file mode 100644 index fd1ff11b65..0000000000 --- a/packages/frontend/src/pages/settings/general.vue +++ /dev/null @@ -1,624 +0,0 @@ - - - - - diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue deleted file mode 100644 index 1f23b6c9d6..0000000000 --- a/packages/frontend/src/pages/settings/import-export.vue +++ /dev/null @@ -1,298 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index b7bf8c5dc1..61e3ca8b6c 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -4,39 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue deleted file mode 100644 index f7dcc7b139..0000000000 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ /dev/null @@ -1,489 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue new file mode 100644 index 0000000000..84c625b502 --- /dev/null +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -0,0 +1,1242 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 0b8e89a6a5..f25e59b1ca 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -4,176 +4,229 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 337cbfc532..21bc74326a 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -4,126 +4,182 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,11 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkButton from '@/components/MkButton.vue'; -import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js'; +import { parseThemeCode, previewTheme, installTheme } from '@/theme.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; +const router = useRouter(); const installThemeCode = ref(null); async function install(code: string): Promise { @@ -35,6 +37,8 @@ async function install(code: string): Promise { type: 'success', text: i18n.tsx._theme.installed({ name: theme.name }), }); + installThemeCode.value = null; + router.push('/settings/theme'); } catch (err) { switch (err.message.toLowerCase()) { case 'this theme is already installed': @@ -59,7 +63,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._theme.install, icon: 'ti ti-download', })); diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index 579ca6b20b..fcd0b293e0 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -33,18 +33,18 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 22b008fb61..877d2deb90 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -76,10 +76,10 @@ import FormSection from '@/components/form/section.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -155,7 +155,7 @@ const headerActions = computed(() => []); // eslint-disable-next-line @typescript-eslint/no-unused-vars const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Edit webhook', icon: 'ti ti-webhook', })); @@ -184,6 +184,6 @@ definePageMetadata(() => ({ .description { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index 727c4df2d6..e853f967cb 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -46,7 +46,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const name = ref(''); const url = ref(''); @@ -82,7 +82,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Create new webhook', icon: 'ti ti-webhook', })); diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue deleted file mode 100644 index af8b7ca945..0000000000 --- a/packages/frontend/src/pages/settings/webhook.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - - - diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 37f6558d64..71f572657b 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -4,9 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> - - diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 58e815d89c..4d9c0bb742 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -4,11 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue index d153dc8726..f9af8e1ee7 100644 --- a/packages/frontend/src/ui/_common_/announcements.vue +++ b/packages/frontend/src/ui/_common_/announcements.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index aa72de6089..f61e178bce 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.timeline }} -