Merge branch 'develop' into 'main'

release: v20240714

Co-authored-by: GitLab CI <project_7_bot_1bfaee5701aed20091a86249a967a6c1@noreply.firefish.dev>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Linerly <linerly@proton.me>
Co-authored-by: jolupa <jolupameister@gmail.com>
Co-authored-by: Eana Hufwe <eana@1a23.com>
Co-authored-by: mester yui <oscarodriguez56@gmail.com>

See merge request 
This commit is contained in:
naskya 2024-07-13 15:06:55 +00:00
commit 784c5724a1
285 changed files with 12476 additions and 11948 deletions
.gitignore.gitlab-ci.yml
.gitlab/issue_templates
Cargo.lockCargo.tomlDockerfileREADME.mdbiome.json
docs
locales
package.json
packages

3
.gitignore vendored
View file

@ -57,9 +57,6 @@ packages/backend/assets/instance.css
packages/backend/assets/sounds/None.mp3
packages/backend/assets/LICENSE
packages/megalodon/lib
packages/megalodon/.idea
dev/container/firefish
dev/container/db
dev/container/redis

View file

@ -30,6 +30,7 @@ stages:
- build
- dependency
- clean
- manage
variables:
POSTGRES_DB: 'firefish_db'
@ -108,7 +109,6 @@ test:build:backend_ts:
paths:
- packages/backend/**/*
- packages/firefish-js/**/*
- packages/megalodon/**/*
when: always
before_script:
- apt-get update && apt-get -y upgrade
@ -128,7 +128,7 @@ test:build:backend_ts:
- psql --host postgres --user "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" --command 'CREATE EXTENSION pgroonga'
script:
- pnpm install --frozen-lockfile
- pnpm --filter 'backend' --filter 'firefish-js' --filter 'megalodon' run build:debug
- pnpm --filter 'backend' --filter 'firefish-js' run build:debug
- pnpm run migrate
- psql --host postgres --user "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" --command "$(cat docs/downgrade.sql)"
@ -175,29 +175,7 @@ build:container:
image: docker.io/debian:trixie-slim
services: []
rules:
- if: $BUILD == 'true'
when: always
- if: $BUILD == 'false'
when: never
- if: $CI_COMMIT_BRANCH == 'develop'
changes:
paths:
- packages/**/*
- locales/**/*
- scripts/copy-assets.mjs
- package.json
- Cargo.toml
- Cargo.lock
- Dockerfile
- .dockerignore
when: always
needs:
- job: test:build
optional: true
- job: test:build:backend_ts_only
optional: true
- job: test:build:client_only
optional: true
- if: $BUILD && $CI_PIPELINE_SOURCE == 'schedule'
variables:
STORAGE_DRIVER: overlay
before_script:
@ -206,8 +184,8 @@ build:container:
sed -i -r 's/"version": "([-0-9]+)",/"version": "\1-dev",/' package.json
- apt-get install -y --no-install-recommends ca-certificates fuse-overlayfs buildah
- echo "${CI_REGISTRY_PASSWORD}" | buildah login --username "${CI_REGISTRY_USER}" --password-stdin "${CI_REGISTRY}"
- export IMAGE_TAG="${CI_REGISTRY}/${CI_PROJECT_PATH}/develop:not-for-production"
- export IMAGE_CACHE="${CI_REGISTRY}/${CI_PROJECT_PATH}/develop/cache"
- export IMAGE_TAG_1="${CI_REGISTRY}/${CI_PROJECT_PATH}/develop:not-for-production"
- export IMAGE_TAG_2="${CI_REGISTRY}/${CI_PROJECT_PATH}/develop:not-for-production-$(date +%Y%m%d)"
- buildah version
script:
- |-
@ -218,13 +196,12 @@ build:container:
--security-opt apparmor=unconfined \
--cap-add all \
--platform linux/amd64 \
--layers \
--cache-to "${IMAGE_CACHE}" \
--cache-from "${IMAGE_CACHE}" \
--tag "${IMAGE_TAG}" \
--tag "${IMAGE_TAG_1}" \
--tag "${IMAGE_TAG_2}" \
.
- buildah inspect "${IMAGE_TAG}"
- buildah push "${IMAGE_TAG}"
- buildah inspect "${IMAGE_TAG_1}"
- buildah push "${IMAGE_TAG_1}"
- buildah push "${IMAGE_TAG_2}"
cargo:check:msrv:
stage: test
@ -363,3 +340,27 @@ clean:
- pnpm install --frozen-lockfile
script:
- pnpm run clean-all
add-issue-labels:
stage: manage
rules:
- if: $ADD_LABEL && $CI_PIPELINE_SOURCE == 'schedule'
image: registry.firefish.dev/firefish/gitlab-issue-labels
variables:
GITLAB_HOST: "firefish.dev"
services: []
before_script: []
script:
- gitlab-issue-labels
close-stalled-issues:
stage: manage
rules:
- if: $CLOSE_STALLED && $CI_PIPELINE_SOURCE == 'schedule'
image: registry.firefish.dev/firefish/gitlab-issue-labels
variables:
GITLAB_HOST: "firefish.dev"
services: []
before_script: []
script:
- gitlab-issue-labels

View file

@ -16,16 +16,19 @@
<!-- If this happens on your device and has to do with the user interface, it's client-side. If this happens on either with the API or the backend, or you got a server-side error in the client, it's server-side. -->
<!-- Uncomment (remove surrounding arrow signs) the following line(s) to specify the category of this issue. -->
<!-- /label Server -->
<!-- /label Client -->
<!-- /label Mobile -->
<!-- /label Third-party-client -->
<!-- /label Docs -->
<!-- /label Locale -->
<!-- /label "Build from source" -->
<!-- /label Container -->
<!-- /label "Firefish API" -->
<!-- /label "Mastodon API" -->
<!-- * label: Server -->
<!-- * label: Client -->
<!-- * label: Mobile -->
<!-- * label: Third-party-client -->
<!-- * label: Docs -->
<!-- * label: Locale -->
<!-- * label: Build from source -->
<!-- * label: Container -->
<!-- * label: Firefish API -->
<!-- * label: Mastodon API -->
<!-- Please do not edit the next line -->
* label: Bug
## What happened?
<!-- Please give us a brief description of what happened. -->
@ -43,7 +46,7 @@
<!-- Is it always reproducible, or is it conditional/probabilistic ? -->
## What did you try to solve the issue
## What did you try to solve the issue / Do you have any insights
<!-- Not to repeat the same thing, let us share what you have tried so far. -->
@ -81,8 +84,7 @@ By submitting this issue, you agree to follow our [Contribution Guidelines](http
- [ ] I have searched the issue tracker for similar issues, and this is not a duplicate.
## Are you willing to fix this bug? (optional)
<!-- Please uncomment the following line if you want to fix this bug -->
<!-- /assign me -->
- [ ] Yes, I will open a merge request that closes this ticket.
<!--
Please tell us how to fix this bug.
@ -95,8 +97,3 @@ By submitting this issue, you agree to follow our [Contribution Guidelines](http
Many thanks for your involvement!
-->
<!-- Do not edit the following line -->
/label Bug?

View file

@ -16,16 +16,19 @@
<!-- If this happens on your device and has to do with the user interface, it's client-side. If this happens on either with the API or the backend, or you got a server-side error in the client, it's server-side. -->
<!-- Uncomment (remove surrounding arrow signs) the following line(s) to specify the category of this issue. -->
<!-- /label Server -->
<!-- /label Client -->
<!-- /label Mobile -->
<!-- /label Third-party-client -->
<!-- /label Docs -->
<!-- /label Locale -->
<!-- /label "Build from source" -->
<!-- /label Container -->
<!-- /label "Firefish API" -->
<!-- /label "Mastodon API" -->
<!-- * label: Server -->
<!-- * label: Client -->
<!-- * label: Mobile -->
<!-- * label: Third-party-client -->
<!-- * label: Docs -->
<!-- * label: Locale -->
<!-- * label: Build from source -->
<!-- * label: Container -->
<!-- * label: Firefish API -->
<!-- * label: Mastodon API -->
<!-- Please do not edit the next line -->
* label: Discussion
## What do you think needs to be discussed?
<!-- Please tell us your idea. -->
@ -64,8 +67,7 @@ By submitting this issue, you agree to follow our [Contribution Guidelines](http
- [ ] I have searched the issue tracker for similar issues, and this is not a duplicate.
## Are you willing to open a merge request? (optional)
<!-- Please uncomment the following line if you want to implement this yourself -->
<!-- /assign me -->
- [ ] Yes, I will open a merge request that closes this ticket.
<!--
Please tell us how do you want to implement your idea.
@ -78,8 +80,3 @@ By submitting this issue, you agree to follow our [Contribution Guidelines](http
Many thanks for your involvement!
-->
<!-- Do not edit the following line -->
/label Discussion

View file

@ -16,16 +16,19 @@
<!-- If this happens on your device and has to do with the user interface, it's client-side. If this happens on either with the API or the backend, or you got a server-side error in the client, it's server-side. -->
<!-- Uncomment (remove surrounding arrow signs) the following line(s) to specify the category of this issue. -->
<!-- /label Server -->
<!-- /label Client -->
<!-- /label Mobile -->
<!-- /label Third-party-client -->
<!-- /label Docs -->
<!-- /label Locale -->
<!-- /label "Build from source" -->
<!-- /label Container -->
<!-- /label "Firefish API" -->
<!-- /label "Mastodon API" -->
<!-- * label: Server -->
<!-- * label: Client -->
<!-- * label: Mobile -->
<!-- * label: Third-party-client -->
<!-- * label: Docs -->
<!-- * label: Locale -->
<!-- * label: Build from source -->
<!-- * label: Container -->
<!-- * label: Firefish API -->
<!-- * label: Mastodon API -->
<!-- Please do not edit the next line -->
* label: Feature
## What feature would you like implemented?
<!-- Please give us a brief description of what you'd like to be refactored. -->
@ -49,8 +52,7 @@ By submitting this issue, you agree to follow our [Contribution Guidelines](http
- [ ] I have searched the issue tracker for similar requests, and this is not a duplicate.
## Are you willing to implement this feature? (optional)
<!-- Please uncomment the following line if you want to implement this feature -->
<!-- /assign me -->
- [ ] Yes, I will open a merge request that closes this ticket.
<!--
Please tell us how to implement this feature.
@ -63,8 +65,3 @@ By submitting this issue, you agree to follow our [Contribution Guidelines](http
Many thanks for your involvement!
-->
<!-- Do not edit the following line -->
/label Feature

View file

@ -16,16 +16,19 @@
<!-- If this happens on your device and has to do with the user interface, it's client-side. If this happens on either with the API or the backend, or you got a server-side error in the client, it's server-side. -->
<!-- Uncomment (remove surrounding arrow signs) the following line(s) to specify the category of this issue. -->
<!-- /label Server -->
<!-- /label Client -->
<!-- /label Mobile -->
<!-- /label Third-party-client -->
<!-- /label Docs -->
<!-- /label Locale -->
<!-- /label "Build from source" -->
<!-- /label Container -->
<!-- /label "Firefish API" -->
<!-- /label "Mastodon API" -->
<!-- * label: Server -->
<!-- * label: Client -->
<!-- * label: Mobile -->
<!-- * label: Third-party-client -->
<!-- * label: Docs -->
<!-- * label: Locale -->
<!-- * label: Build from source -->
<!-- * label: Container -->
<!-- * label: Firefish API -->
<!-- * label: Mastodon API -->
<!-- Please do not edit the next line -->
* label: Refactor
## What parts of the code do you think should be refactored?
<!-- Please give us a brief description of what you'd like. -->
@ -49,8 +52,7 @@ By submitting this issue, you agree to follow our [Contribution Guidelines](http
- [ ] I have searched the issue tracker for similar requests, and this is not a duplicate.
## Are you willing to refactor the code? (optional)
<!-- Please uncomment the following line if you want to implement it -->
<!-- /assign me -->
- [ ] Yes, I will open a merge request that closes this ticket.
<!--
Please tell us how to refactor the code.
@ -63,8 +65,3 @@ By submitting this issue, you agree to follow our [Contribution Guidelines](http
Many thanks for your involvement!
-->
<!-- Do not edit the following line -->
/label Refactor

102
Cargo.lock generated
View file

@ -92,7 +92,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -143,7 +143,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -154,7 +154,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -410,9 +410,9 @@ checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
[[package]]
name = "cc"
version = "1.0.105"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5208975e568d83b6b05cc0a063c8e7e9acc2b43bee6da15616a5b73e109d7437"
checksum = "eaff6f8ce506b9773fa786672d63fc7a191ffea1be33f72bbd4aeacefca9ffc8"
dependencies = [
"jobserver",
"libc",
@ -629,7 +629,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f"
dependencies = [
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -772,7 +772,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -1388,7 +1388,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -1467,7 +1467,7 @@ checksum = "0122b7114117e64a63ac49f752a5ca4624d534c7b1c7de796ac196381cd2d947"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -1496,7 +1496,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -1744,7 +1744,7 @@ dependencies = [
"convert_case",
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -1840,7 +1840,7 @@ dependencies = [
"napi-derive-backend",
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -1855,7 +1855,7 @@ dependencies = [
"quote",
"regex",
"semver",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -1885,9 +1885,9 @@ dependencies = [
[[package]]
name = "nom-exif"
version = "1.2.0"
version = "1.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4da85c96824dc1b583f55fd2c39e53b04482b4c199f25db27868db334bf06407"
checksum = "3dac386e49252f313c88764101ca16681caa4a5b0260d494d58b6af7f04edd3a"
dependencies = [
"chrono",
"nom",
@ -1984,7 +1984,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -2076,7 +2076,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -2137,7 +2137,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -2295,7 +2295,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -2468,7 +2468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd"
dependencies = [
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -2664,9 +2664,9 @@ dependencies = [
[[package]]
name = "rgb"
version = "0.8.42"
version = "0.8.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eeba50c58624afb3be6d04abad8cb7a259d52017068c9f828975aa870a5daf5"
checksum = "1aee83dc281d5a3200d37b299acd13b81066ea126a7f16f0eae70fc9aed241d9"
dependencies = [
"bytemuck",
]
@ -2839,7 +2839,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -2878,7 +2878,7 @@ dependencies = [
"proc-macro2",
"quote",
"sea-bae",
"syn 2.0.69",
"syn 2.0.71",
"unicode-ident",
]
@ -2955,7 +2955,7 @@ checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -3406,9 +3406,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.69"
version = "2.0.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "201fcda3845c23e8212cd466bfebf0bd20694490fc0356ae8e428e0824a915a6"
checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462"
dependencies = [
"proc-macro2",
"quote",
@ -3435,14 +3435,14 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
name = "sysinfo"
version = "0.30.12"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732ffa00f53e6b2af46208fba5718d9662a421049204e156328b66791ffa15ae"
checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3"
dependencies = [
"cfg-if",
"core-foundation-sys",
@ -3467,9 +3467,9 @@ dependencies = [
[[package]]
name = "target-lexicon"
version = "0.12.14"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f"
checksum = "4873307b7c257eddcb50c9bedf158eb669578359fb28428bef438fec8e6ba7c2"
[[package]]
name = "tempfile"
@ -3485,22 +3485,22 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.61"
version = "1.0.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
checksum = "f2675633b1499176c2dff06b0856a27976a8f9d436737b4cf4f312d4d91d8bbb"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.61"
version = "1.0.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
checksum = "d20468752b09f49e909e55a5d338caa8bedf615594e9d80bc4c565d30faf798c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -3566,9 +3566,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.7.0"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6b6a2fb3a985e99cebfaefa9faa3024743da73304ca1c683a36429613d3d22"
checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
dependencies = [
"tinyvec_macros",
]
@ -3605,7 +3605,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -3668,9 +3668,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.22.14"
version = "0.22.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38"
checksum = "d59a3a72298453f564e2b111fa896f8d07fabb36f51f06d7e875fc5e0b5a3ef1"
dependencies = [
"indexmap",
"serde",
@ -3699,7 +3699,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -3827,9 +3827,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.9.1"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
dependencies = [
"serde",
]
@ -3911,7 +3911,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
"wasm-bindgen-shared",
]
@ -3933,7 +3933,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -4214,7 +4214,7 @@ checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
"synstructure 0.13.1",
]
@ -4235,7 +4235,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]
@ -4255,7 +4255,7 @@ checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
"synstructure 0.13.1",
]
@ -4284,7 +4284,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.69",
"syn 2.0.71",
]
[[package]]

View file

@ -26,7 +26,7 @@ emojis = { version = "0.6.2", default-features = false }
idna = { version = "1.0.2", default-features = false }
image = { version = "0.25.1", default-features = false }
isahc = { version = "1.7.2", default-features = false }
nom-exif = { version = "1.2.0", default-features = false }
nom-exif = { version = "1.2.6", default-features = false }
once_cell = { version = "1.19.0", default-features = false }
pretty_assertions = { version = "1.4.0", default-features = false }
proc-macro2 = { version = "1.0.86", default-features = false }
@ -39,9 +39,9 @@ sea-orm = { version = "0.12.15", default-features = false }
serde = { version = "1.0.204", default-features = false }
serde_json = { version = "1.0.120", default-features = false }
serde_yaml = { version = "0.9.34", default-features = false }
syn = { version = "2.0.69", default-features = false }
sysinfo = { version = "0.30.12", default-features = false }
thiserror = { version = "1.0.61", default-features = false }
syn = { version = "2.0.71", default-features = false }
sysinfo = { version = "0.30.13", default-features = false }
thiserror = { version = "1.0.62", default-features = false }
tokio = { version = "1.38.0", default-features = false }
tokio-test = { version = "0.4.4", default-features = false }
tracing = { version = "0.1.40", default-features = false }

View file

@ -36,7 +36,6 @@ COPY packages/backend/package.json packages/backend/package.json
COPY packages/client/package.json packages/client/package.json
COPY packages/sw/package.json packages/sw/package.json
COPY packages/firefish-js/package.json packages/firefish-js/package.json
COPY packages/megalodon/package.json packages/megalodon/package.json
COPY pnpm-lock.yaml ./
# Install dev mode dependencies for compilation
@ -60,8 +59,6 @@ RUN apk update && apk add --no-cache zip unzip tini ffmpeg curl
COPY . ./
COPY --from=build /firefish/packages/megalodon /firefish/packages/megalodon
# Copy node modules
COPY --from=build /firefish/node_modules /firefish/node_modules
COPY --from=build /firefish/packages/backend/node_modules /firefish/packages/backend/node_modules

View file

@ -17,13 +17,13 @@ Firefish is based off of Misskey, a powerful microblogging server on ActivityPub
# Documents
- [Installation guide](./docs/install.md)
- [Installation guide](https://firefish.dev/firefish/firefish/-/blob/main/docs/install.md)
- [Contributing guide](./CONTRIBUTING.md)
- [Changelog](./docs/changelog.md)
- [Changelog](https://firefish.dev/firefish/firefish/-/blob/main/docs/changelog.md)
# Links
- Donations: <https://opencollective.com/Firefish>
- Donations: <https://opencollective.com/firefish>
- Matrix space: <https://matrix.to/#/#firefish-community:nitro.chat>
- Official account: <a href="https://info.firefish.dev/@firefish" rel="me">`@firefish@info.firefish.dev`</a>
- Weblate: <https://hosted.weblate.org/engage/firefish/>
@ -31,6 +31,6 @@ Firefish is based off of Misskey, a powerful microblogging server on ActivityPub
# Want to get involved? Great!
- If you know how to program in TypeScript, Vue, or Rust, please read the [contributing guide](./CONTRIBUTING.md).
- If you have the means to, [donations](https://opencollective.com/Firefish) are a great way to keep us going.
- If you have the means to, [donations](https://opencollective.com/firefish) are a great way to keep us going.
- If you know a non-English language, translating Firefish on [Weblate](https://hosted.weblate.org/engage/firefish/) help bring Firefish to more people. No technical experience needed!

View file

@ -45,7 +45,6 @@
"style": {
"noCommaOperator": "error",
"noInferrableTypes": "error",
"noNamespace": "error",
"noNonNullAssertion": "warn",
"noUselessElse": "off",
"noVar": "error",
@ -398,6 +397,20 @@
}
}
}
},
{
"include": ["packages/backend/src/server/api/mastodon/**/*.ts"],
"linter": {
"rules": {
"style": {
"noParameterAssign": "off"
},
"complexity": {
"noStaticOnlyClass": "off",
"noThisInStatic": "off"
}
}
}
}
]
}

View file

@ -2,6 +2,137 @@
Breaking changes are indicated by the :warning: icon.
## v20240714
- The old Mastodon API has been replaced with a new implementation based on Iceshrimps.
- :warning: The new API uses a new format to manage Mastodon sessions in the database, whereas old implementation uses Misskey sessions. All previous client app and token registrations will not work with the new API. All clients need to be re-registered and all users need to re-authenticate.
- :warning: All IDs (of statuses/notes, notifications, users, etc.) will be using the alphanumerical format, aligning with the Firefish/Misskey API. The old numerical IDs will not work when queried against the new API.
<details>
<summary>Available endpoints (under <code>https://instance-domain/api/</code>)</summary>
| method | endpoint | note |
|----------|------------------------------------|--------------------------------------------|
| `POST` | `oauth/token` | |
| `POST` | `oauth/revoke` | |
| `POST` | `v1/apps` | |
| `GET` | `v1/apps/verify_credentials` | |
| `POST` | `v1/firefish/apps/info` | Firefish extension, uses MiAuth |
| `POST` | `v1/firefish/auth/code` | Firefish extension, uses MiAuth |
| | | |
| `GET` | `v1/accounts/verify_credentials` | |
| `PATCH` | `v1/accounts/update_credentials` | |
| `GET` | `v1/accounts/lookup` | |
| `GET` | `v1/accounts/relationships` | |
| `GET` | `v1/accounts/search` | |
| `GET` | `v1/accounts/:id` | |
| `GET` | `v1/accounts/:id/statuses` | |
| `GET` | `v1/accounts/:id/featured_tags` | |
| `GET` | `v1/accounts/:id/followers` | |
| `GET` | `v1/accounts/:id/following` | |
| `GET` | `v1/accounts/:id/lists` | |
| `POST` | `v1/accounts/:id/follow` | |
| `POST` | `v1/accounts/:id/unfollow` | |
| `POST` | `v1/accounts/:id/block` | |
| `POST` | `v1/accounts/:id/unblock` | |
| `POST` | `v1/accounts/:id/mute` | |
| `POST` | `v1/accounts/:id/unmute` | |
| | | |
| `GET` | `v1/featured_tags` | always returns an empty list |
| `GET` | `v1/followed_tags` | always returns an empty list |
| `GET` | `v1/bookmarks` | |
| `GET` | `v1/favourites` | |
| | | |
| `GET` | `v1/mutes` | |
| `GET` | `v1/blocks` | |
| `GET` | `v1/follow_requests` | |
| `POST` | `v1/follow_requests/:id/authorize` | |
| `POST` | `v1/follow_requests/:id/reject` | |
| | | |
| `GET` | `v1/filters` | |
| `POST` | `v1/filters` | |
| `GET` | `v2/filters` | |
| `POST` | `v2/filters` | |
| | | |
| `GET` | `v1/lists` | |
| `POST` | `v1/lists` | |
| `GET` | `v1/lists/:id` | |
| `PUT` | `v1/lists/:id` | |
| `DELETE` | `v1/lists/:id` | |
| `GET` | `v1/lists/:id/accounts` | |
| `POST` | `v1/lists/:id/accounts` | |
| `DELETE` | `v1/lists/:id/accounts` | |
| | | |
| `GET` | `v1/media/:id` | |
| `PUT` | `v1/media/:id` | |
| `POST` | `v1/media` | |
| `POST` | `v2/media` | |
| | | |
| `GET` | `v1/custom_emojis` | |
| `GET` | `v1/instance` | |
| `GET` | `v2/instance` | |
| `GET` | `v1/announcements` | |
| `POST` | `v1/announcements/:id/dismiss` | |
| `GET` | `v1/trends` | pagination is unimplemented |
| `GET` | `v1/trends/tags` | pagination is unimplemented |
| `GET` | `v1/trends/statuses` | |
| `GET` | `v1/trends/links` | always returns an empty list |
| `GET` | `v1/preferences` | |
| `GET` | `v2/suggestions` | |
| | | |
| `GET` | `v1/notifications` | |
| `GET` | `v1/notifications/:id` | |
| `POST` | `v1/notifications/clear` | |
| `POST` | `v1/notifications/:id/dismiss` | |
| `POST` | `v1/conversations/:id/read` | |
| `GET` | `v1/push/subscription` | |
| `POST` | `v1/push/subscription` | |
| `DELETE` | `v1/push/subscription` | |
| | | |
| `GET` | `v1/search` | |
| `GET` | `v2/search` | |
| | | |
| `POST` | `v1/statuses` | |
| `PUT` | `v1/statuses/:id` | |
| `GET` | `v1/statuses/:id` | |
| `DELETE` | `v1/statuses/:id` | |
| `GET` | `v1/statuses/:id/context` | |
| `GET` | `v1/statuses/:id/history` | |
| `GET` | `v1/statuses/:id/source` | |
| `GET` | `v1/statuses/:id/reblogged_by` | |
| `GET` | `v1/statuses/:id/favourited_by` | |
| `POST` | `v1/statuses/:id/favourite` | |
| `POST` | `v1/statuses/:id/unfavourite` | |
| `POST` | `v1/statuses/:id/reblog` | |
| `POST` | `v1/statuses/:id/unreblog` | |
| `POST` | `v1/statuses/:id/bookmark` | |
| `POST` | `v1/statuses/:id/unbookmark` | |
| `POST` | `v1/statuses/:id/pin` | |
| `POST` | `v1/statuses/:id/unpin` | |
| `POST` | `v1/statuses/:id/react/:name` | |
| `POST` | `v1/statuses/:id/unreact/:name` | |
| `POST` | `v1/statuses/:id/translate` | |
| | | |
| `GET` | `v1/polls/:id` | |
| `POST` | `v1/polls/:id/votes` | |
| | | |
| `GET` | `v1/scheduled_statuses` | |
| `GET` | `v1/scheduled_statuses/:id` | reschedule (`PUT` method) is unimplemented |
| `DELETE` | `v1/scheduled_statuses/:id` | |
| | | |
| `GET` | `v1/streaming/health` | |
| | | |
| `GET` | `v1/timelines/public` | |
| `GET` | `v1/timelines/tag/:hashtag` | |
| `GET` | `v1/timelines/home` | |
| `GET` | `v1/timelines/list/:listId` | |
| `GET` | `v1/conversations` | |
| `GET` | `v1/markers` | |
| `POST` | `v1/markers` | |
</details>
## v20240710
- Added `readCatLanguage` field to the response of `i` and request of `i/update` (optional).

View file

@ -2,8 +2,28 @@
Critical security updates are indicated by the :warning: icon.
- Server administrators must check [notice-for-admins.md](./notice-for-admins.md) as well.
- Third-party client/bot developers may want to check [api-change.md](./api-change.md) as well.
- Server administrators must check [notice-for-admins.md](https://firefish.dev/firefish/firefish/-/blob/main/docs/notice-for-admins.md) as well.
- Third-party client/bot developers may want to check [api-change.md](https://firefish.dev/firefish/firefish/-/blob/main/docs/api-change.md) as well.
## [v20240714](https://firefish.dev/firefish/firefish/-/merge_requests/11146/commits)
- Mastodon API implementation was ported from Iceshrimp, with added Firefish extensions including push notifications, post languages, schedule post support, and more. (#10880)
- Fix bugs
### Acknowledgement
The new Mastodon API support would not have been possible without the significant dedication of Laura Hausmann (Iceshrimp lead developer). We thank her and other Iceshrimp contributors from the bottom of our hearts.
### Breaking changes
- The new Mastodon API uses a new format to manage Mastodon sessions in the database, whereas old implementation uses Misskey sessions. All previous client app and token registrations will not work with the new API. All clients need to be re-registered and all users need to re-authenticate.
- All IDs (of statuses/notes, notifications, users, etc.) will be using the alphanumerical format, aligning with the Firefish/Misskey API. The old numerical IDs will not work when queried against the new API.
### Important Notice
The new Mastodon API support still contains some incompatibilities and unimplemented features, so please keep in mind that you may experience glitchy behavior, and please do NOT report such issues to Mastodon client apps. Such a “bug” is likely due to our implementation, and Mastodon client developers should not be bothered by such an invalid bug report. In the worst scenario, they may simply block non-Mastodon implementations (some clients already do that).
If you find an incompatibility issue (a bug not reproducible with a vanilla Mastodon server), file it to the Firefish repository instead. However, please remember that it is impossible to achieve 100% compatibility, given that Mastodon servers dont behave exactly like its own documentation.
## [v20240710](https://firefish.dev/firefish/firefish/-/merge_requests/11110/commits)

View file

@ -1,6 +1,11 @@
BEGIN;
DELETE FROM "migrations" WHERE name IN (
'CreateSystemActors1720618854585',
'AddMastodonSubscriptionType1715181461692',
'SwSubscriptionAccessToken1709395223611',
'UserProfileMentions1711075007936',
'ClientCredentials1713108561474',
'TurnOffCatLanguage1720107645050',
'RefactorScheduledPosts1716804636187',
'RemoveEnumTypenameSuffix1716462794927',
@ -35,6 +40,20 @@ DELETE FROM "migrations" WHERE name IN (
'RemoveNativeUtilsMigration1705877093218'
);
-- addMastodonSubscriptionType
ALTER TABLE "sw_subscription" DROP COLUMN "subscriptionTypes";
DROP TYPE "push_subscription_type";
-- sw-subscription-per-access-token
ALTER TABLE "sw_subscription" DROP CONSTRAINT "FK_98a1aa2db2a5253924f42f38767";
ALTER TABLE "sw_subscription" DROP COLUMN "appAccessTokenId";
-- user-profile-mentions
ALTER TABLE "user_profile" DROP COLUMN "mentions";
-- client-credential-support
ALTER TABLE "access_token" ALTER COLUMN "userId" SET NOT NULL;
-- turn-off-cat-language
ALTER TABLE "user" DROP COLUMN "readCatLanguage";

View file

@ -51,4 +51,4 @@ NOTE: This will take some time to come fully online, even after download and ext
Once the server is up you can use a web browser to access the web interface at `http://serverip:3000` (where `serverip` is the IP of the server you are running the firefish server on).
To publish your server, please follow the instructions in [section 5 of this installation guide](./install.md#5-preparation-for-publishing-a-server).
To publish your server, please follow the instructions in [section 5 of this installation guide](https://firefish.dev/firefish/firefish/-/blob/main/docs/install.md#5-preparation-for-publishing-a-server).

View file

@ -35,7 +35,7 @@ We don't test Firefish on non-Linux systems, so please install Firefish on such
<details>
<summary>Possible setup on FreeBSD (as of version `20240630`)</summary>
<summary>Possible setup on FreeBSD (as of version <code>20240630</code>)</summary>
You can install Firefish on FreeBSD by adding these extra steps to the standard instructions:
@ -365,7 +365,7 @@ In this instruction, we use [Caddy](https://caddyserver.com/) to make the Firefi
## Upgrade Firefish version
Please refer to the [upgrade instruction](./upgrade.md). Be sure to switch to `firefish` user and go to the Firefish directory before executing the `git` command:
Please refer to the [upgrade instruction](https://firefish.dev/firefish/firefish/-/blob/main/docs/upgrade.md). Be sure to switch to `firefish` user and go to the Firefish directory before executing the `git` command:
```sh
sudo su --login firefish

View file

@ -1,11 +1,21 @@
# Notice for server administrators
You can skip intermediate versions when upgrading from an old version, but please read the notices and follow the instructions for each intermediate version before [upgrading](./upgrade.md).
You can skip intermediate versions when upgrading from an old version, but please read the notices and follow the instructions for each intermediate version before [upgrading](https://firefish.dev/firefish/firefish/-/blob/main/docs/upgrade.md).
## Upcoming breaking change (unreleased)
Please take a look at #10947.
## v20240714
### For systemd/pm2 users
You can remove the `packages/megalodon` directory after pulling the latest source code (`git pull --ff origin main`).
```sh
rm --recursive --force packages/megalodon
```
## v20240710
### For all users

View file

@ -2,7 +2,7 @@
## For systemd/pm2 users
1. Check [`docs/notice-for-admins.md`](./notice-for-admins.md)
1. Check [`docs/notice-for-admins.md`](https://firefish.dev/firefish/firefish/-/blob/main/docs/notice-for-admins.md)
1. Stop the server
```sh
sudo systemctl stop your-firefish-service.service
@ -28,7 +28,7 @@
## For Docker/Podman users
1. Check [`docs/notice-for-admins.md`](./notice-for-admins.md)
1. Check [`docs/notice-for-admins.md`](https://firefish.dev/firefish/firefish/-/blob/main/docs/notice-for-admins.md)
1. Pull the latest container image
```sh
docker pull registry.firefish.dev/firefish/firefish:latest

View file

@ -2324,3 +2324,4 @@ addAlt4MeTag: "Afegeix automàticament l'etiqueta #Alt4Me a les teves publicacio
que tinguin un fitxer adjunt sense descripció"
strongPassword: Bona contrasenya
turnOffCatLanguage: Desactiva la conversió al llenguatge de gat
announcement: Anunci

View file

@ -293,6 +293,7 @@ usernameOrUserId: "Username or user id"
noSuchUser: "User not found"
lookup: "Lookup"
announcements: "Announcements"
announcement: "Announcement"
imageUrl: "Image URL"
remove: "Delete"
removed: "Successfully deleted"

View file

@ -2189,3 +2189,9 @@ noSentFollowRequests: No has enviado ninguna solicitud de seguimiento
antennaLimit: El número máximo de antenas que cada usuario puede crear
toEdit: Editar
cannotEditVisibility: No puedes editar la visibilidad
i18nServerChange: Use el {language} en su lugar.
i18nServerSet: Use el {language} para nuevos clientes.
squareCatAvatars: Mostrar avatares cuadrados para las cuentas de gatos
useThisAccountConfirm: ¿Quieres continuar con esta cuenta?
i18nServerInfo: Nuevos clientes estarán en {language} por defecto.
media: Medios

View file

@ -463,7 +463,7 @@ usernameInvalidFormat: "Hanya dapat menerima karakter a-z, A-Z dan angka 0-9."
tooShort: "Terlalu pendek"
tooLong: "Terlalu panjang"
weakPassword: "Kata sandi lemah"
normalPassword: "Kata sandi baik"
normalPassword: "Kata sandi sedang"
veryStrongPassword: "Kata sandi kuat"
passwordMatched: "Kata sandi sama"
passwordNotMatched: "Kata sandi tidak sama"
@ -1120,7 +1120,7 @@ _wordMute:
regular expressions."
softDescription: "Sembunyikan postingan yang memenuhi aturan kondisi dari lini masa."
hardDescription: "Cegah postingan memenuhi aturan kondisi dari ditambahkan ke lini
masa. Dengan tambahan, kiriman berikut tidak akan ditambahkan ke lini masa meskipun
masa. Dengan tambahan, postingan berikut tidak akan ditambahkan ke lini masa meskipun
jika kondisi tersebut diubah."
soft: "Lembut"
hard: "Keras"
@ -1223,8 +1223,8 @@ _sfx:
antenna: "Penerimaan Antenna"
channel: "Pemberitahuan saluran"
_ago:
future: "Masa depan"
justNow: "Baru saja"
future: "masa depan"
justNow: "baru saja"
secondsAgo: "{n} detik lalu"
minutesAgo: "{n} menit lalu"
hoursAgo: "{n} jam lalu"
@ -1255,7 +1255,7 @@ _tutorial:
step3_4: "Bingung tidak berpikiran untuk mengatakan sesuatu? Coba saja \"baru aja
ikutan bikin akun misskey punyaku\"!"
step4_1: "Mari kita lihat kamu di sana."
step4_2: "Untuk kiriman pertama kamu, beberapa orang biasanya membuat postingan
step4_2: "Untuk postingan pertama kamu, beberapa orang biasanya membuat postingan
{introduction} atau \"Halo dunia!\" yang sederhana."
step5_1: "Linimasa, linimasa di mana-mana!"
step5_2: "Servermu memiliki {timelines} lini masa berbeda yang diaktifkan."
@ -1890,7 +1890,7 @@ recommended: Direkomendasikan
silenceThisInstance: Bisukan server ini
hiddenTags: Tagar Tersembunyi
preferencesBackups: Preferensi cadangan
editNote: Sunting kiriman
editNote: Sunting postingan
deleted: Dihapus
edited: Disunting pada {date} {time}
selectInstance: Pilih server
@ -2148,12 +2148,12 @@ confirm: Konfirmasi
importZip: Impor ZIP
exportZip: Ekspor ZIP
detectPostLanguage: Deteksi bahasa secara otomatis dan tampilkan tombol terjemahkan
untuk kiriman dalam bahasa asing
indexableDescription: Perbolehkan pencarian di sini untuk menampilkan kiriman publikmu
untuk postingan dalam bahasa asing
indexableDescription: Perbolehkan pencarian di sini untuk menampilkan postingan publikmu
indexable: Dapat diindeks
languageForTranslation: Bahasa terjemahan kiriman
languageForTranslation: Bahasa terjemahan postingan
openServerInfo: Tampilkan informasi server dengan mengeklik ticker server di sebuah
kiriman
postingan
vibrate: Putar getaran
clickToShowPatterns: Klik untuk menampilkan pola modul
iconSet: Set ikon
@ -2199,52 +2199,52 @@ forMobile: Ponsel
replaceChatButtonWithAccountButton: Ganti tombol percakapan dengan tombol ganti akun
replaceWidgetsButtonWithReloadButton: Ganti tombol widget dengan tombol muat ulang
searchEngine: Mesin pencarian yang digunakan dalam bilah pencarian MFM
postSearch: Pencarian kiriman di server ini
showBigPostButton: Tampilkan tombol kirim besar di formulir kiriman
showPreviewByDefault: Tampilkan pratinjau dalam formulir kiriman secara bawaan
postSearch: Pencarian postingan di server ini
showBigPostButton: Tampilkan tombol kirim besar di formulir postingan
showPreviewByDefault: Tampilkan pratinjau dalam formulir postingan secara bawaan
useCdn: Dapatkan aset dari CDN
useCdnDescription: Muat aset statis seperti Twemoji dari CDN JSDelivr daripada server
Firefish ini.
sentFollowRequests: Permintaan mengikuti terkirim
replyUnmute: Suarakan balasan dalam lini masa
noSentFollowRequests: Kamu belum punya permintaan mengikuti
noSentFollowRequests: Kamu belum mengirim permintaan mengikuti
enablePullToRefresh: Aktifkan "Tarik ke bawah untuk memuat ulang"
pullDownToReload: Tarik ke bawah untuk memuat ulang
enableTimelineStreaming: Perbarui lini masa secara otomatis
searchWords: Kata-kata untuk dicari / ID atau URL untuk dicari
searchUsers: Dikirim oleh (opsional)
searchCwAndAlt: Termasuk peringatan konten dan deskripsi berkas
searchPostsWithFiles: Hanya kiriman dengan berkas
searchPostsWithFiles: Hanya postingan dengan berkas
publishTimelines: Terbitkan lini masa untuk pengunjung
publishTimelinesDescription: Jika diaktifkan, lini masa Lokal dan Global akan ditampilkan
di {url} bahkan ketika keluar dari akun.
searchWordsDescription: "Masukkan kata kunci di sini untuk mencari kiriman. Pisahkan
searchWordsDescription: "Masukkan kata kunci di sini untuk mencari postingan. Pisahkan
kata dengan spasi untuk pencarian AND (dan), atau 'OR' ('atau', tanpa tanda kutip)
di antara kata-kata untuk pencarian OR.\nMisalnya, 'pagi malam' akan menemukan postingan
yang mengandung 'pagi' dan 'malam', dan 'pagi OR malam' akan menemukan postingan
yang mengandung 'pagi' atau 'malam' (atau keduanya).\nAnda juga dapat menggabungkan
kondisi AND/OR seperti '(pagi OR malam) mengantuk'.\n\nJika kamu ingin membuka halaman
pengguna atau halaman postingan tertentu, masukkan ID atau URL pada kolom ini dan
klik tombol 'Cari'. Mengklik 'Cari' akan mencari postingan yang secara harfiah mengandung
ID/URL."
kondisi AND/OR seperti '(pagi OR malam) mengantuk'.\n\n Jika kamu ingin membuka
halaman pengguna atau halaman postingan tertentu, masukkan ID atau URL pada kolom
ini dan klik tombol 'Cari'. Mengeklik 'Cari' akan mencari postingan yang secara
harfiah mengandung ID/URL."
pullToRefreshThreshold: Jarak penarikan untuk memuat ulang
releaseToReload: Lepaskan untuk memuat ulang
reloading: Memuat ulang
replyMute: Bisukan balasan dalam lini masa
searchRange: Dikirim dalam (opsional)
searchUsersDescription: "Untuk mencari kiriman oleh pengguna/server tertentu, masukkan
searchUsersDescription: "Untuk mencari postingan oleh pengguna/server tertentu, masukkan
ID (@pengguna@contoh.id, atau @pengguna untuk pengguna lokal) atau nama domain (contoh.id)\n
\nJika kamu memasukkan 'me' ('aku', tanpa tanda kutip), semua kirimanmu (termasuk
kiriman yang tidak terdaftar, khusus pengikut, langsung, dan rahasia) akan dicari.\n
\nJika kamu memasukkan 'me' ('aku', tanpa tanda kutip), semua postinganmu (termasuk
postingan yang tidak terdaftar, khusus pengikut, langsung, dan rahasia) akan dicari.\n
\nJika Anda memasukkan 'local' (tanpa tanda kutip), hasilnya akan disaring untuk
menyertakan hanya kiriman dari server ini."
menyertakan hanya postingan dari server ini."
searchRangeDescription: "Jika kamu ingin memfilter periode waktu, masukkan dalam format
ini: 20220615-20231031\n\nJika kamu menghilangkan tahun (seperti 0105-0106 atau
20231105-0110), maka akan ditafsirkan sebagai tahun saat ini.\n\nKamu juga bisa
menghilangkan tanggal awal atau akhir. Sebagai contoh, -0102 akan memfilter hasil
pencarian untuk menampilkan hanya kiriman yang dibuat sebelum tanggal 2 Januari
pencarian untuk menampilkan hanya postingan yang dibuat sebelum tanggal 2 Januari
tahun ini, dan 20231026- akan memfilter hasil pencarian untuk menampilkan hanya
kiriman yang dibuat setelah tanggal 26 Oktober 2023."
postingan yang dibuat setelah tanggal 26 Oktober 2023."
toPost: Kirim
toQuote: Kutip
noAltTextWarning: Beberapa berkas yang dilampirkan tidak memiliki deskripsi. Lupa
@ -2258,14 +2258,14 @@ messagingUnencryptedInfo: Percakapan di Firefish tidak terenkripsi secara ujung
moderationNote: Catatan Moderasi
driveCapacityOverride: Penimpaan Kapasitas Drive
ipFirstAcknowledged: Tanggal akuisisi pertama dari alamat IP
incorrectLanguageWarning: "Sepertinya kirimanmu dalam bahasa {detected}, tetapi Anda
memilih {current}.\nApakah kamu ingin ubah bahasanya ke bahasa {detected} saja?"
autocorrectNoteLanguage: Tampilkan peringatan jika bahasa kiriman tidak cocok dengan
incorrectLanguageWarning: "Sepertinya postinganmu dalam bahasa {detected}, tetapi
Anda memilih {current}.\nApakah kamu ingin ubah bahasanya ke bahasa {detected} saja?"
autocorrectNoteLanguage: Tampilkan peringatan jika bahasa postingan tidak cocok dengan
hasil yang dideteksi secara otomatis
markLocalFilesNsfwByDefault: Tandai semua berkas lokal baru sensitif secara bawaan
markLocalFilesNsfwByDefaultDescription: Terlepas dari pengaturan ini, pengguna dapat
menghapus sendiri tanda NSFW. Berkas yang ada tidak berpengaruh.
noteEditHistory: Riwayat penyuntingan kiriman
noteEditHistory: Riwayat penyuntingan postingan
media: Media
antennaLimit: Jumlah antena maksimum yang dapat dibuat oleh setiap pengguna
showAddFileDescriptionAtFirstPost: Buka formulir secara otomatis untuk menulis deskripsi
@ -2278,3 +2278,28 @@ useThisAccountConfirm: Apakah kamu ingin melanjutkan dengan akun ini?
inputAccountId: Silakan memasukkan akunmu (misalnya, @firefish@info.firefish.dev)
copyRemoteFollowUrl: Salin URL ikuti jarak jauh
slashQuote: Kutipan rantai
scheduledPostAt: Kiriman akan dikirim {time}
scheduledPost: Jadwalkan postingan ini
_later:
monthsAgo: dalam {n}bln
secondsAgo: dalam {n}d
future: masa depan
justNow: saat ini
daysAgo: dalam {n}hr
hoursAgo: dalam {n}j
minutesAgo: dalam {n}m
weeksAgo: dalam {m}mg
yearsAgo: dalam {n}thn
mergeRenotesInTimeline: Kelompokkan beberapa pembagian postingan yang sama
cancelScheduledPost: Hapus jadwal
scheduledDate: Tanggal terjadwal
mergeThreadInTimeline: Gabungkan beberapa postingan dalam utas yang sama dalam lini
masa
strongPassword: Kata sandi baik
announcement: Pengumuman
i18nServerSet: Gunakan {language} untuk klien baru.
turnOffCatLanguage: Matikan konversi bahasa kucing
i18nServerInfo: Klien baru akan dalam {language} secara bawaan.
i18nServerChange: Gunakan {language} saja.
addAlt4MeTag: 'Tambahkan tagar #Alt4Me secara otomatis pada postinganmu jika berkas
yang dilampirkan tidak ada deskripsi'

View file

@ -254,6 +254,7 @@ usernameOrUserId: "ユーザー名かユーザーID"
noSuchUser: "ユーザーが見つかりません"
lookup: "照会"
announcements: "お知らせ"
announcement: "お知らせ"
imageUrl: "画像URL"
remove: "削除"
removed: "削除しました"

View file

@ -243,6 +243,7 @@ usernameOrUserId: "用户名或用户 ID"
noSuchUser: "用户不存在"
lookup: "查询"
announcements: "公告"
announcement: "公告"
imageUrl: "图片 URL"
remove: "删除"
removed: "已删除"

View file

@ -242,6 +242,7 @@ usernameOrUserId: "使用者名稱或使用者ID"
noSuchUser: "使用者不存在"
lookup: "查詢"
announcements: "公告"
announcement: "公告"
imageUrl: "圖片URL"
remove: "刪除"
removed: "已成功刪除"

View file

@ -1,6 +1,6 @@
{
"name": "firefish",
"version": "20240710",
"version": "20240714",
"repository": {
"type": "git",
"url": "https://firefish.dev/firefish/firefish.git"

View file

@ -8,4 +8,3 @@ This directory contains all of the packages Firefish uses.
- `client`: Web interface written in Vue3 and TypeScript
- `sw`: Web [Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) written in TypeScript
- `firefish-js`: TypeScript SDK for both backend and client
- `megalodon`: TypeScript library used for partial Mastodon API compatibility

View file

@ -30,7 +30,7 @@ export interface AccessToken {
createdAt: DateTimeWithTimeZone
token: string
hash: string
userId: string
userId: string | null
appId: string | null
lastUsedAt: DateTimeWithTimeZone | null
session: string | null
@ -257,6 +257,8 @@ export interface Config {
userAgent: string
}
export declare function countLocalUsers(): Promise<number>
export declare function countReactions(reactions: Record<string, number>): Record<string, number>
export interface Cpu {
@ -417,8 +419,6 @@ export interface FollowRequest {
/** Converts milliseconds to a human readable string. */
export declare function formatMilliseconds(milliseconds: number): string
export declare function fromMastodonId(mastodonId: string): string | null
export interface GalleryLike {
id: string
createdAt: DateTimeWithTimeZone
@ -461,6 +461,8 @@ export declare function getFullApAccount(username: string, host?: string | undef
export declare function getImageSizeFromUrl(url: string): Promise<ImageSize>
export declare function getInternalActor(actor: InternalActor): Promise<User>
export declare function getNoteSummary(fileIds: Array<string>, text: string | undefined | null, cw: string | undefined | null, hasPoll: boolean): string
export declare function getTimestamp(id: string): number
@ -542,6 +544,9 @@ export interface Instance {
faviconUrl: string | null
}
export type InternalActor = 'instance'|
'relay';
/**
* Checks if a server is allowlisted.
* Returns `Ok(true)` if private mode is disabled.
@ -1149,6 +1154,17 @@ export type PushNotificationKind = 'generic'|
'readAllNotifications'|
'mastodon';
export type PushSubscriptionType = 'adminReport'|
'adminSignUp'|
'favourite'|
'follow'|
'followRequest'|
'mention'|
'poll'|
'reblog'|
'status'|
'update';
export interface RedisConfig {
host: string
port: number
@ -1294,6 +1310,8 @@ export interface Software20 {
/** Escapes `%` and `\` in the given string. */
export declare function sqlLikeEscape(src: string): string
export declare function sqlRegexEscape(src: string): string
export interface Storage {
/** Total storage space in bytes */
total: number
@ -1313,6 +1331,8 @@ export interface SwSubscription {
auth: string
publickey: string
sendReadMessage: boolean
appAccessTokenId: string | null
subscriptionTypes: Array<PushSubscriptionType>
}
export interface SysLogConfig {
@ -1327,8 +1347,6 @@ export interface TlsConfig {
export declare function toDbReaction(reaction?: string | undefined | null, host?: string | undefined | null): Promise<string>
export declare function toMastodonId(firefishId: string): string | null
export declare function toPuny(host: string): string
export declare function unwatchNote(watcherId: string, noteId: string): Promise<void>
@ -1509,6 +1527,7 @@ export interface UserProfile {
preventAiLearning: boolean
isIndexable: boolean
mutedPatterns: Array<string>
mentions: Json
mutedInstances: Array<string>
mutedWords: Array<string>
lang: string | null

View file

@ -366,6 +366,7 @@ module.exports.AntennaSrc = nativeBinding.AntennaSrc
module.exports.ChatEvent = nativeBinding.ChatEvent
module.exports.ChatIndexEvent = nativeBinding.ChatIndexEvent
module.exports.checkWordMute = nativeBinding.checkWordMute
module.exports.countLocalUsers = nativeBinding.countLocalUsers
module.exports.countReactions = nativeBinding.countReactions
module.exports.cpuInfo = nativeBinding.cpuInfo
module.exports.cpuUsage = nativeBinding.cpuUsage
@ -379,13 +380,13 @@ module.exports.fetchMeta = nativeBinding.fetchMeta
module.exports.fetchNodeinfo = nativeBinding.fetchNodeinfo
module.exports.FILE_TYPE_BROWSERSAFE = nativeBinding.FILE_TYPE_BROWSERSAFE
module.exports.formatMilliseconds = nativeBinding.formatMilliseconds
module.exports.fromMastodonId = nativeBinding.fromMastodonId
module.exports.generateSecureRandomString = nativeBinding.generateSecureRandomString
module.exports.generateUserToken = nativeBinding.generateUserToken
module.exports.genId = nativeBinding.genId
module.exports.genIdAt = nativeBinding.genIdAt
module.exports.getFullApAccount = nativeBinding.getFullApAccount
module.exports.getImageSizeFromUrl = nativeBinding.getImageSizeFromUrl
module.exports.getInternalActor = nativeBinding.getInternalActor
module.exports.getNoteSummary = nativeBinding.getNoteSummary
module.exports.getTimestamp = nativeBinding.getTimestamp
module.exports.greet = nativeBinding.greet
@ -393,6 +394,7 @@ module.exports.hashPassword = nativeBinding.hashPassword
module.exports.HOUR = nativeBinding.HOUR
module.exports.Inbound = nativeBinding.Inbound
module.exports.initializeRustLogger = nativeBinding.initializeRustLogger
module.exports.InternalActor = nativeBinding.InternalActor
module.exports.isAllowedServer = nativeBinding.isAllowedServer
module.exports.isBlockedServer = nativeBinding.isBlockedServer
module.exports.isOldPasswordAlgorithm = nativeBinding.isOldPasswordAlgorithm
@ -427,6 +429,7 @@ module.exports.publishToGroupChatStream = nativeBinding.publishToGroupChatStream
module.exports.publishToModerationStream = nativeBinding.publishToModerationStream
module.exports.publishToNotesStream = nativeBinding.publishToNotesStream
module.exports.PushNotificationKind = nativeBinding.PushNotificationKind
module.exports.PushSubscriptionType = nativeBinding.PushSubscriptionType
module.exports.RelayStatus = nativeBinding.RelayStatus
module.exports.removeOldAttestationChallenges = nativeBinding.removeOldAttestationChallenges
module.exports.safeForSql = nativeBinding.safeForSql
@ -435,10 +438,10 @@ module.exports.sendPushNotification = nativeBinding.sendPushNotification
module.exports.shouldNyaify = nativeBinding.shouldNyaify
module.exports.showServerInfo = nativeBinding.showServerInfo
module.exports.sqlLikeEscape = nativeBinding.sqlLikeEscape
module.exports.sqlRegexEscape = nativeBinding.sqlRegexEscape
module.exports.storageUsage = nativeBinding.storageUsage
module.exports.stringToAcct = nativeBinding.stringToAcct
module.exports.toDbReaction = nativeBinding.toDbReaction
module.exports.toMastodonId = nativeBinding.toMastodonId
module.exports.toPuny = nativeBinding.toPuny
module.exports.unwatchNote = nativeBinding.unwatchNote
module.exports.updateAntennaCache = nativeBinding.updateAntennaCache

View file

@ -8,7 +8,7 @@
"binaryName": "backend-rs"
},
"devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.56"
"@napi-rs/cli": "3.0.0-alpha.58"
},
"scripts": {
"build": "napi build --features napi --no-const-enum --platform --release --output-dir ./built/",

View file

@ -346,7 +346,7 @@ pub fn load_config() -> Config {
hostname,
redis_key_prefix,
scheme,
ws_scheme: ws_scheme.to_string(),
ws_scheme: ws_scheme.to_owned(),
}
}

View file

@ -62,7 +62,7 @@ fn wildcard(category: Category) -> String {
/// # use backend_rs::database::cache;
/// # async fn f() -> Result<(), Box<dyn std::error::Error>> {
/// let key = "apple";
/// let data = "I want to cache this string".to_string();
/// let data = "I want to cache this string".to_owned();
///
/// // caches the data for 10 seconds
/// cache::set(key, &data, 10).await?;
@ -106,7 +106,7 @@ pub async fn set<V: for<'a> Deserialize<'a> + Serialize>(
/// # use backend_rs::database::cache;
/// # async fn f() -> Result<(), Box<dyn std::error::Error>> {
/// let key = "banana";
/// let data = "I want to cache this string".to_string();
/// let data = "I want to cache this string".to_owned();
///
/// // set cache
/// cache::set(key, &data, 10).await?;
@ -145,7 +145,7 @@ pub async fn get<V: for<'a> Deserialize<'a> + Serialize>(key: &str) -> Result<Op
/// # use backend_rs::database::cache;
/// # async fn f() -> Result<(), Box<dyn std::error::Error>> {
/// let key = "chocolate";
/// let value = "I want to cache this string".to_string();
/// let value = "I want to cache this string".to_owned();
///
/// // set cache
/// cache::set(key, &value, 10).await?;
@ -248,12 +248,12 @@ mod unit_test {
let value_1: Vec<i32> = vec![1, 2, 3, 4, 5];
let key_2 = "CARGO_TEST_CACHE_KEY_2";
let value_2 = "Hello fedizens".to_string();
let value_2 = "Hello fedizens".to_owned();
let key_3 = "CARGO_TEST_CACHE_KEY_3";
let value_3 = Data {
id: 1000000007,
kind: "prime number".to_string(),
kind: "prime number".to_owned(),
};
set(key_1, &value_1, 1).await.unwrap();
@ -287,7 +287,7 @@ mod unit_test {
let key_2 = "fish";
let key_3 = "awawa";
let value_1 = "hello".to_string();
let value_1 = "hello".to_owned();
let value_2 = 998244353u32;
let value_3 = 'あ';

View file

@ -57,12 +57,12 @@ async fn init_conn_pool() -> Result<(), RedisError> {
};
if let Some(user) = &redis.user {
params.push(user.to_string())
params.push(user.to_owned())
}
if let Some(pass) = &redis.pass {
params.push(format!(":{}@", pass))
params.push(format!(":{}@", urlencoding::encode(pass)))
}
params.push(redis.host.to_string());
params.push(redis.host.to_owned());
params.push(format!(":{}", redis.port));
params.push(format!("/{}", redis.db));
@ -111,8 +111,8 @@ pub async fn get_conn() -> Result<PooledConnection<'static, RedisConnectionManag
/// prefix Redis key
#[inline]
pub fn key(key: impl ToString) -> String {
format!("{}:{}", CONFIG.redis_key_prefix, key.to_string())
pub fn key(key: impl std::fmt::Display) -> String {
format!("{}:{}", CONFIG.redis_key_prefix, key)
}
#[cfg(test)]

View file

@ -8,7 +8,7 @@ pub struct Acct {
}
#[derive(thiserror::Error, Debug)]
#[doc = "Error type to indicate a string-to-[`Acct`] conversion failure"]
#[doc = "Error type to indicate a [`String`]-to-[`Acct`] conversion failure"]
#[error("failed to convert string '{0}' into acct")]
pub struct InvalidAcctString(String);
@ -25,11 +25,11 @@ impl FromStr for Acct {
.collect();
Ok(Self {
username: split[0].to_string(),
username: split[0].to_owned(),
host: if split.len() == 1 {
None
} else {
Some(split[1].to_string())
Some(split[1].to_owned())
},
})
}
@ -70,11 +70,11 @@ mod unit_test {
#[test]
fn acct_to_string() {
let remote_acct = Acct {
username: "firefish".to_string(),
host: Some("example.com".to_string()),
username: "firefish".to_owned(),
host: Some("example.com".to_owned()),
};
let local_acct = Acct {
username: "MisakaMikoto".to_string(),
username: "MisakaMikoto".to_owned(),
host: None,
};
@ -87,11 +87,11 @@ mod unit_test {
#[test]
fn string_to_acct() {
let remote_acct = Acct {
username: "firefish".to_string(),
host: Some("example.com".to_string()),
username: "firefish".to_owned(),
host: Some("example.com".to_owned()),
};
let local_acct = Acct {
username: "MisakaMikoto".to_string(),
username: "MisakaMikoto".to_owned(),
host: None,
};

View file

@ -0,0 +1,87 @@
//! In-memory internal actor cache handler
// TODO: refactoring
use super::*;
use crate::{database::db_conn, model::entity::user};
use sea_orm::prelude::*;
use std::sync::Mutex;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
#[doc = "database error"]
Db(#[from] DbErr),
#[error("{} does not exist", Acct::from(.0.to_owned()))]
#[doc = "internal actor does not exist"]
InternalActorNotFound(InternalActor),
}
static INSTANCE_ACTOR: Mutex<Option<user::Model>> = Mutex::new(None);
static RELAY_ACTOR: Mutex<Option<user::Model>> = Mutex::new(None);
fn set_instance_actor(value: &user::Model) {
let _ = INSTANCE_ACTOR
.lock()
.map(|mut cache| *cache = Some(value.to_owned()));
}
fn set_relay_actor(value: &user::Model) {
let _ = RELAY_ACTOR
.lock()
.map(|mut cache| *cache = Some(value.to_owned()));
}
async fn cache_instance_actor() -> Result<user::Model, Error> {
let actor = user::Entity::find()
.filter(user::Column::Username.eq(INSTANCE_ACTOR_USERNAME))
.filter(user::Column::Host.is_null())
.one(db_conn().await?)
.await?;
if let Some(actor) = actor {
set_instance_actor(&actor);
Ok(actor)
} else {
Err(Error::InternalActorNotFound(InternalActor::Instance))
}
}
async fn cache_relay_actor() -> Result<user::Model, Error> {
let actor = user::Entity::find()
.filter(user::Column::Username.eq(RELAY_ACTOR_USERNAME))
.filter(user::Column::Host.is_null())
.one(db_conn().await?)
.await?;
if let Some(actor) = actor {
set_relay_actor(&actor);
Ok(actor)
} else {
Err(Error::InternalActorNotFound(InternalActor::Relay))
}
}
// for napi export
// https://github.com/napi-rs/napi-rs/issues/2060
type User = user::Model;
#[macros::export(js_name = "getInternalActor")]
pub async fn get(actor: InternalActor) -> Result<User, Error> {
match actor {
InternalActor::Instance => {
if let Some(cache) = INSTANCE_ACTOR.lock().ok().and_then(|cache| cache.clone()) {
tracing::debug!("Using cached instance.actor");
return Ok(cache);
}
tracing::debug!("Caching instance.actor");
cache_instance_actor().await
}
InternalActor::Relay => {
if let Some(cache) = RELAY_ACTOR.lock().ok().and_then(|cache| cache.clone()) {
tracing::debug!("Using cached relay.actor");
return Ok(cache);
}
tracing::debug!("Caching relay.actor");
cache_relay_actor().await
}
}
}

View file

@ -0,0 +1,34 @@
mod cache;
pub use cache::get;
use super::acct::Acct;
#[derive(Debug)]
#[macros::derive_clone_and_export(string_enum = "lowercase")]
pub enum InternalActor {
Instance,
Relay,
}
const INSTANCE_ACTOR_USERNAME: &str = "instance.actor";
const RELAY_ACTOR_USERNAME: &str = "relay.actor";
// TODO: When `std::mem::variant_count` is stabilized, use
// it to count system actors instead of hard coding the magic number
pub const INTERNAL_ACTORS: u64 = 2;
impl From<InternalActor> for Acct {
fn from(actor: InternalActor) -> Self {
match actor {
InternalActor::Instance => Acct {
username: INSTANCE_ACTOR_USERNAME.to_owned(),
host: None,
},
InternalActor::Relay => Acct {
username: RELAY_ACTOR_USERNAME.to_owned(),
host: None,
},
}
}
}

View file

@ -1,4 +1,5 @@
//! Services used to federate with other servers
pub mod acct;
pub mod internal_actor;
pub mod nodeinfo;

View file

@ -109,12 +109,12 @@ mod unit_test {
let links_1 = NodeinfoLinks {
links: vec![
NodeinfoLink {
rel: "https://example.com/incorrect/schema/2.0".to_string(),
href: "https://example.com/dummy".to_string(),
rel: "https://example.com/incorrect/schema/2.0".to_owned(),
href: "https://example.com/dummy".to_owned(),
},
NodeinfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href: "https://example.com/real".to_string(),
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_owned(),
href: "https://example.com/real".to_owned(),
},
],
};
@ -126,12 +126,12 @@ mod unit_test {
let links_2 = NodeinfoLinks {
links: vec![
NodeinfoLink {
rel: "https://example.com/incorrect/schema/2.0".to_string(),
href: "https://example.com/dummy".to_string(),
rel: "https://example.com/incorrect/schema/2.0".to_owned(),
href: "https://example.com/dummy".to_owned(),
},
NodeinfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.1".to_string(),
href: "https://example.com/real".to_string(),
rel: "http://nodeinfo.diaspora.software/ns/schema/2.1".to_owned(),
href: "https://example.com/real".to_owned(),
},
],
};
@ -143,12 +143,12 @@ mod unit_test {
let links_3 = NodeinfoLinks {
links: vec![
NodeinfoLink {
rel: "https://example.com/incorrect/schema/2.0".to_string(),
href: "https://example.com/dummy/2.0".to_string(),
rel: "https://example.com/incorrect/schema/2.0".to_owned(),
href: "https://example.com/dummy/2.0".to_owned(),
},
NodeinfoLink {
rel: "https://example.com/incorrect/schema/2.1".to_string(),
href: "https://example.com/dummy/2.1".to_string(),
rel: "https://example.com/incorrect/schema/2.1".to_owned(),
href: "https://example.com/dummy/2.1".to_owned(),
},
],
};

View file

@ -4,6 +4,7 @@ use crate::{
config::{local_server_info, CONFIG},
database::db_conn,
federation::nodeinfo::schema::*,
misc,
model::entity::{note, user},
};
use sea_orm::prelude::*;
@ -33,9 +34,10 @@ async fn statistics() -> Result<(u64, u64, u64, u64), DbErr> {
const MONTH: chrono::TimeDelta = chrono::Duration::days(30);
const HALF_YEAR: chrono::TimeDelta = chrono::Duration::days(183);
let local_users = user::Entity::find()
.filter(user::Column::Host.is_null())
.count(db);
let local_users = misc::user::count::local_total(db);
// We don't need to care about the number of system actors here,
// because their last active date is null
let local_active_halfyear = user::Entity::find()
.filter(user::Column::Host.is_null())
.filter(user::Column::LastActiveDate.gt(now - HALF_YEAR))
@ -66,45 +68,45 @@ async fn generate_nodeinfo_2_1() -> Result<Nodeinfo21, DbErr> {
let meta = local_server_info().await?;
let mut metadata = HashMap::from([
(
"nodeName".to_string(),
"nodeName".to_owned(),
json!(meta.name.unwrap_or_else(|| CONFIG.host.clone())),
),
("nodeDescription".to_string(), json!(meta.description)),
("repositoryUrl".to_string(), json!(meta.repository_url)),
("nodeDescription".to_owned(), json!(meta.description)),
("repositoryUrl".to_owned(), json!(meta.repository_url)),
(
"enableLocalTimeline".to_string(),
"enableLocalTimeline".to_owned(),
json!(!meta.disable_local_timeline),
),
(
"enableRecommendedTimeline".to_string(),
"enableRecommendedTimeline".to_owned(),
json!(!meta.disable_recommended_timeline),
),
(
"enableGlobalTimeline".to_string(),
"enableGlobalTimeline".to_owned(),
json!(!meta.disable_global_timeline),
),
(
"enableGuestTimeline".to_string(),
"enableGuestTimeline".to_owned(),
json!(meta.enable_guest_timeline),
),
(
"maintainer".to_string(),
"maintainer".to_owned(),
json!({"name":meta.maintainer_name,"email":meta.maintainer_email}),
),
("proxyAccountName".to_string(), json!(meta.proxy_account_id)),
("proxyAccountName".to_owned(), json!(meta.proxy_account_id)),
(
"themeColor".to_string(),
json!(meta.theme_color.unwrap_or_else(|| "#31748f".to_string())),
"themeColor".to_owned(),
json!(meta.theme_color.unwrap_or_else(|| "#31748f".to_owned())),
),
]);
metadata.shrink_to_fit();
Ok(Nodeinfo21 {
software: Software21 {
name: "firefish".to_string(),
name: "firefish".to_owned(),
version: CONFIG.version.clone(),
repository: Some(meta.repository_url),
homepage: Some("https://firefish.dev/firefish/firefish".to_string()),
homepage: Some("https://firefish.dev/firefish/firefish".to_owned()),
},
protocols: vec![Protocol::Activitypub],
services: Services {

View file

@ -18,15 +18,15 @@ pub fn initialize_logger() {
});
} else if let Some(levels) = &CONFIG.log_level {
// `logLevel` config is Deprecated
if levels.contains(&"trace".to_string()) {
if levels.contains(&"trace".to_owned()) {
builder = builder.with_max_level(Level::TRACE);
} else if levels.contains(&"debug".to_string()) {
} else if levels.contains(&"debug".to_owned()) {
builder = builder.with_max_level(Level::DEBUG);
} else if levels.contains(&"info".to_string()) {
} else if levels.contains(&"info".to_owned()) {
builder = builder.with_max_level(Level::INFO);
} else if levels.contains(&"warning".to_string()) {
} else if levels.contains(&"warning".to_owned()) {
builder = builder.with_max_level(Level::WARN);
} else if levels.contains(&"error".to_string()) {
} else if levels.contains(&"error".to_owned()) {
builder = builder.with_max_level(Level::ERROR);
} else {
// Fallback

View file

@ -26,19 +26,19 @@ pub fn show_server_info() -> Result<(), SysinfoPoisonError> {
tracing::info!(
"Hostname: {}",
System::host_name().unwrap_or_else(|| "unknown".to_string())
System::host_name().unwrap_or_else(|| "unknown".to_owned())
);
tracing::info!(
"OS: {}",
System::long_os_version().unwrap_or_else(|| "unknown".to_string())
System::long_os_version().unwrap_or_else(|| "unknown".to_owned())
);
tracing::info!(
"Kernel: {}",
System::kernel_version().unwrap_or_else(|| "unknown".to_string())
System::kernel_version().unwrap_or_else(|| "unknown".to_owned())
);
tracing::info!(
"CPU architecture: {}",
System::cpu_arch().unwrap_or_else(|| "unknown".to_string())
System::cpu_arch().unwrap_or_else(|| "unknown".to_owned())
);
tracing::info!("CPU threads: {}", system_info.cpus().len());
tracing::info!("Total memory: {} MiB", system_info.total_memory() / 1048576);

View file

@ -81,7 +81,7 @@ pub async fn is_allowed_server(host: &str) -> Result<bool, sea_orm::DbErr> {
return Ok(true);
}
if let Some(allowed_hosts) = meta.allowed_hosts {
return Ok(allowed_hosts.contains(&host.to_string()));
return Ok(allowed_hosts.contains(&host.to_owned()));
}
Ok(false)
}

View file

@ -73,114 +73,114 @@ mod unit_test {
#[test]
fn word_mute_match() {
let texts = [
"The quick brown fox jumps over the lazy dog.".to_string(),
"色は匂へど 散りぬるを 我が世誰ぞ 常ならむ".to_string(),
"😇".to_string(),
"The quick brown fox jumps over the lazy dog.".to_owned(),
"色は匂へど 散りぬるを 我が世誰ぞ 常ならむ".to_owned(),
"😇".to_owned(),
];
let hiragana_1 = r"/[\u{3040}-\u{309f}]/u".to_string();
let hiragana_2 = r"/[あ-ん]/u".to_string();
let katakana_1 = r"/[\u{30a1}-\u{30ff}]/u".to_string();
let katakana_2 = r"/[ア-ン]/u".to_string();
let emoji = r"/[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}]/u".to_string();
let hiragana_1 = r"/[\u{3040}-\u{309f}]/u".to_owned();
let hiragana_2 = r"/[あ-ん]/u".to_owned();
let katakana_1 = r"/[\u{30a1}-\u{30ff}]/u".to_owned();
let katakana_2 = r"/[ア-ン]/u".to_owned();
let emoji = r"/[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}]/u".to_owned();
assert!(check_word_mute_impl(&texts, &[], &["/the/i".to_string()]));
assert!(check_word_mute_impl(&texts, &[], &["/the/i".to_owned()]));
assert!(!check_word_mute_impl(&texts, &[], &["/the/".to_string()]));
assert!(!check_word_mute_impl(&texts, &[], &["/the/".to_owned()]));
assert!(check_word_mute_impl(&texts, &[], &["/QuICk/i".to_string()]));
assert!(check_word_mute_impl(&texts, &[], &["/QuICk/i".to_owned()]));
assert!(!check_word_mute_impl(&texts, &[], &["/QuICk/".to_string()]));
assert!(!check_word_mute_impl(&texts, &[], &["/QuICk/".to_owned()]));
assert!(check_word_mute_impl(
&texts,
&[
"".to_string(),
"有為の奥山 今日越えて 浅き夢見し 酔ひもせず".to_string()
"".to_owned(),
"有為の奥山 今日越えて 浅き夢見し 酔ひもせず".to_owned()
],
&[]
));
assert!(!check_word_mute_impl(
&texts,
&["有為の奥山 今日越えて 浅き夢見し 酔ひもせず".to_string()],
&["有為の奥山 今日越えて 浅き夢見し 酔ひもせず".to_owned()],
&[]
));
assert!(!check_word_mute_impl(
&texts,
&[
"有為の奥山".to_string(),
"今日越えて".to_string(),
"浅き夢見し".to_string(),
"酔ひもせず".to_string()
"有為の奥山".to_owned(),
"今日越えて".to_owned(),
"浅き夢見し".to_owned(),
"酔ひもせず".to_owned()
],
&[]
));
assert!(check_word_mute_impl(
&texts,
&["yellow fox".to_string(), "mastodon".to_string()],
&["yellow fox".to_owned(), "mastodon".to_owned()],
&[hiragana_1.clone()]
));
assert!(check_word_mute_impl(
&texts,
&["yellow fox".to_string(), "mastodon".to_string()],
&["yellow fox".to_owned(), "mastodon".to_owned()],
&[hiragana_2.clone()]
));
assert!(!check_word_mute_impl(
&texts,
&["yellow fox".to_string(), "mastodon".to_string()],
&["yellow fox".to_owned(), "mastodon".to_owned()],
&[katakana_1.clone()]
));
assert!(!check_word_mute_impl(
&texts,
&["yellow fox".to_string(), "mastodon".to_string()],
&["yellow fox".to_owned(), "mastodon".to_owned()],
&[katakana_2.clone()]
));
assert!(check_word_mute_impl(
&texts,
&["brown fox".to_string(), "mastodon".to_string()],
&["brown fox".to_owned(), "mastodon".to_owned()],
&[katakana_1.clone()]
));
assert!(check_word_mute_impl(
&texts,
&["brown fox".to_string(), "mastodon".to_string()],
&["brown fox".to_owned(), "mastodon".to_owned()],
&[katakana_2.clone()]
));
assert!(check_word_mute_impl(
&texts,
&["yellow fox".to_string(), "dog".to_string()],
&["yellow fox".to_owned(), "dog".to_owned()],
&[katakana_1.clone()]
));
assert!(check_word_mute_impl(
&texts,
&["yellow fox".to_string(), "dog".to_string()],
&["yellow fox".to_owned(), "dog".to_owned()],
&[katakana_2.clone()]
));
assert!(check_word_mute_impl(
&texts,
&["yellow fox".to_string(), "mastodon".to_string()],
&["yellow fox".to_owned(), "mastodon".to_owned()],
&[hiragana_1.clone(), katakana_1.clone()]
));
assert!(check_word_mute_impl(
&texts,
&["😇".to_string(), "🥲".to_string(), "🥴".to_string()],
&["😇".to_owned(), "🥲".to_owned(), "🥴".to_owned()],
&[]
));
assert!(!check_word_mute_impl(
&texts,
&["🙂".to_string(), "🥲".to_string(), "🥴".to_string()],
&["🙂".to_owned(), "🥲".to_owned(), "🥴".to_owned()],
&[]
));

View file

@ -1,9 +1,18 @@
use once_cell::sync::Lazy;
use regex::Regex;
/// Escapes `%` and `\` in the given string.
#[macros::export]
pub fn sql_like_escape(src: &str) -> String {
src.replace('%', r"\%").replace('_', r"\_")
}
#[macros::export]
pub fn sql_regex_escape(src: &str) -> String {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[!$()*+.:<=>?\[\]\^{|}-]").unwrap());
RE.replace_all(src, r"\$1").to_string()
}
/// Returns `true` if `src` does not contain suspicious characters like `%`.
#[macros::export]
pub fn safe_for_sql(src: &str) -> bool {

View file

@ -68,7 +68,7 @@ pub async fn get_image_size_from_url(url: &str) -> Result<ImageSize, Error> {
if attempted {
tracing::warn!("attempt limit exceeded: {}", url);
return Err(Error::TooManyAttempts(url.to_string()));
return Err(Error::TooManyAttempts(url.to_owned()));
}
tracing::info!("retrieving image from {}", url);

View file

@ -1,34 +0,0 @@
#[macros::export]
pub fn to_mastodon_id(firefish_id: &str) -> Option<String> {
let decoded: [u8; 16] = basen::BASE36.decode_var_len(firefish_id)?;
Some(basen::BASE10.encode_var_len(&decoded))
}
#[macros::export]
pub fn from_mastodon_id(mastodon_id: &str) -> Option<String> {
let decoded: [u8; 16] = basen::BASE10.decode_var_len(mastodon_id)?;
Some(basen::BASE36.encode_var_len(&decoded))
}
#[cfg(test)]
mod unit_test {
use pretty_assertions::assert_eq;
#[test]
fn to_mastodon_id() {
assert_eq!(
super::to_mastodon_id("9pdqi3rjl4lxirq3").unwrap(),
"2145531976185871567229403"
);
assert_eq!(super::to_mastodon_id("9pdqi3r*irq3"), None);
}
#[test]
fn from_mastodon_id() {
assert_eq!(
super::from_mastodon_id("2145531976185871567229403").unwrap(),
"9pdqi3rjl4lxirq3"
);
assert_eq!(super::from_mastodon_id("9pdqi3rjl4lxirq3"), None);
}
}

View file

@ -10,7 +10,6 @@ pub mod get_image_size;
pub mod is_quote;
pub mod is_safe_url;
pub mod latest_version;
pub mod mastodon_id;
pub mod note;
pub mod nyaify;
pub mod password;
@ -18,3 +17,4 @@ pub mod reaction;
pub mod remove_old_attestation_challenges;
pub mod should_nyaify;
pub mod system_info;
pub mod user;

View file

@ -15,12 +15,12 @@ pub fn summarize_impl(
match file_ids.len() {
0 => (),
1 => buf.push("📎".to_string()),
1 => buf.push("📎".to_owned()),
n => buf.push(format!("📎 ({})", n)),
};
if has_poll {
buf.push("📊".to_string())
buf.push("📊".to_owned())
}
buf.join(" ")
@ -94,7 +94,7 @@ mod unit_test {
fn summarize_note() {
let note = NoteLike {
file_ids: vec![],
text: Some("Hello world!".to_string()),
text: Some("Hello world!".to_owned()),
cw: None,
has_poll: false,
};
@ -102,26 +102,26 @@ mod unit_test {
let note_with_cw = NoteLike {
file_ids: vec![],
text: Some("Hello world!".to_string()),
cw: Some("Content warning".to_string()),
text: Some("Hello world!".to_owned()),
cw: Some("Content warning".to_owned()),
has_poll: false,
};
assert_eq!(summarize!(note_with_cw), "Content warning");
let note_with_file_and_cw = NoteLike {
file_ids: vec!["9s7fmcqogiq4igin".to_string()],
file_ids: vec!["9s7fmcqogiq4igin".to_owned()],
text: None,
cw: Some("Selfie, no ec".to_string()),
cw: Some("Selfie, no ec".to_owned()),
has_poll: false,
};
assert_eq!(summarize!(note_with_file_and_cw), "Selfie, no ec 📎");
let note_with_files_only = NoteLike {
file_ids: vec![
"9s7fmcqogiq4igin".to_string(),
"9s7qrld5u14cey98".to_string(),
"9s7gebs5zgts4kca".to_string(),
"9s5z3e4vefqd29ee".to_string(),
"9s7fmcqogiq4igin".to_owned(),
"9s7qrld5u14cey98".to_owned(),
"9s7gebs5zgts4kca".to_owned(),
"9s5z3e4vefqd29ee".to_owned(),
],
text: None,
cw: None,
@ -131,13 +131,13 @@ mod unit_test {
let note_all = NoteLike {
file_ids: vec![
"9s7fmcqogiq4igin".to_string(),
"9s7qrld5u14cey98".to_string(),
"9s7gebs5zgts4kca".to_string(),
"9s5z3e4vefqd29ee".to_string(),
"9s7fmcqogiq4igin".to_owned(),
"9s7qrld5u14cey98".to_owned(),
"9s7gebs5zgts4kca".to_owned(),
"9s5z3e4vefqd29ee".to_owned(),
],
text: Some("Hello world!".to_string()),
cw: Some("Content warning".to_string()),
text: Some("Hello world!".to_owned()),
cw: Some("Content warning".to_owned()),
has_poll: true,
};
assert_eq!(summarize!(note_all), "Content warning 📎 (4) 📊");

View file

@ -128,12 +128,12 @@ mod unit_test {
#[test]
fn decode_reaction() {
let unicode_emoji_1 = DecodedReaction {
reaction: "".to_string(),
reaction: "".to_owned(),
name: None,
host: None,
};
let unicode_emoji_2 = DecodedReaction {
reaction: "🩷".to_string(),
reaction: "🩷".to_owned(),
name: None,
host: None,
};
@ -145,23 +145,23 @@ mod unit_test {
assert_ne!(super::decode_reaction("🩷"), unicode_emoji_1);
let unicode_emoji_3 = DecodedReaction {
reaction: "🖖🏿".to_string(),
reaction: "🖖🏿".to_owned(),
name: None,
host: None,
};
assert_eq!(super::decode_reaction("🖖🏿"), unicode_emoji_3);
let local_emoji = DecodedReaction {
reaction: ":meow_melt_tears@.:".to_string(),
name: Some("meow_melt_tears".to_string()),
reaction: ":meow_melt_tears@.:".to_owned(),
name: Some("meow_melt_tears".to_owned()),
host: None,
};
assert_eq!(super::decode_reaction(":meow_melt_tears:"), local_emoji);
let remote_emoji_1 = DecodedReaction {
reaction: ":meow_uwu@some-domain.example.org:".to_string(),
name: Some("meow_uwu".to_string()),
host: Some("some-domain.example.org".to_string()),
reaction: ":meow_uwu@some-domain.example.org:".to_owned(),
name: Some("meow_uwu".to_owned()),
host: Some("some-domain.example.org".to_owned()),
};
assert_eq!(
super::decode_reaction(":meow_uwu@some-domain.example.org:"),
@ -169,9 +169,9 @@ mod unit_test {
);
let remote_emoji_2 = DecodedReaction {
reaction: ":C++23@xn--eckwd4c7c.example.org:".to_string(),
name: Some("C++23".to_string()),
host: Some("xn--eckwd4c7c.example.org".to_string()),
reaction: ":C++23@xn--eckwd4c7c.example.org:".to_owned(),
name: Some("C++23".to_owned()),
host: Some("xn--eckwd4c7c.example.org".to_owned()),
};
assert_eq!(
super::decode_reaction(":C++23@xn--eckwd4c7c.example.org:"),
@ -179,14 +179,14 @@ mod unit_test {
);
let invalid_reaction_1 = DecodedReaction {
reaction: ":foo".to_string(),
reaction: ":foo".to_owned(),
name: None,
host: None,
};
assert_eq!(super::decode_reaction(":foo"), invalid_reaction_1);
let invalid_reaction_2 = DecodedReaction {
reaction: ":foo&@example.com:".to_string(),
reaction: ":foo&@example.com:".to_owned(),
name: None,
host: None,
};

View file

@ -38,9 +38,9 @@ pub fn cpu_info() -> Result<Cpu, SysinfoPoisonError> {
model: match system_info.cpus() {
[] => {
tracing::debug!("failed to get CPU info");
"unknown".to_string()
"unknown".to_owned()
}
cpus => cpus[0].brand().to_string(),
cpus => cpus[0].brand().to_owned(),
},
cores: system_info.cpus().len() as u16,
})

View file

@ -0,0 +1,17 @@
use crate::{federation::internal_actor::INTERNAL_ACTORS, model::entity::user};
use sea_orm::prelude::*;
pub async fn local_total(db: &DbConn) -> Result<u64, DbErr> {
user::Entity::find()
.filter(user::Column::Host.is_null())
.count(db)
.await
.map(|count| count - INTERNAL_ACTORS)
}
#[macros::ts_export(js_name = "countLocalUsers")]
pub async fn local_total_js() -> Result<u32, DbErr> {
local_total(crate::database::db_conn().await?)
.await
.map(|count| count as u32)
}

View file

@ -0,0 +1 @@
pub mod count;

View file

@ -15,7 +15,7 @@ pub struct Model {
pub token: String,
pub hash: String,
#[sea_orm(column_name = "userId")]
pub user_id: String,
pub user_id: Option<String>,
#[sea_orm(column_name = "appId")]
pub app_id: Option<String>,
#[sea_orm(column_name = "lastUsedAt")]
@ -41,6 +41,8 @@ pub enum Relation {
App,
#[sea_orm(has_many = "super::notification::Entity")]
Notification,
#[sea_orm(has_many = "super::sw_subscription::Entity")]
SwSubscription,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
@ -63,6 +65,12 @@ impl Related<super::notification::Entity> for Entity {
}
}
impl Related<super::sw_subscription::Entity> for Entity {
fn to() -> RelationDef {
Relation::SwSubscription.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()

View file

@ -128,6 +128,36 @@ pub enum PollNoteVisibility {
#[derive(Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[macros::derive_clone_and_export(string_enum = "camelCase")]
#[sea_orm(
rs_type = "String",
db_type = "Enum",
enum_name = "push_subscription_type"
)]
pub enum PushSubscriptionType {
#[sea_orm(string_value = "admin.report")]
AdminReport,
#[sea_orm(string_value = "admin.sign_up")]
AdminSignUp,
#[sea_orm(string_value = "favourite")]
Favourite,
#[sea_orm(string_value = "follow")]
Follow,
#[sea_orm(string_value = "follow_request")]
FollowRequest,
#[sea_orm(string_value = "mention")]
Mention,
#[sea_orm(string_value = "poll")]
Poll,
#[sea_orm(string_value = "reblog")]
Reblog,
#[sea_orm(string_value = "status")]
Status,
#[sea_orm(string_value = "update")]
Update,
}
#[derive(Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[macros::derive_clone_and_export(string_enum = "camelCase")]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "relay_status")]
pub enum RelayStatus {
#[sea_orm(string_value = "accepted")]

View file

@ -1,5 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
use super::sea_orm_active_enums::PushSubscriptionType;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@ -19,10 +20,22 @@ pub struct Model {
pub publickey: String,
#[sea_orm(column_name = "sendReadMessage")]
pub send_read_message: bool,
#[sea_orm(column_name = "appAccessTokenId")]
pub app_access_token_id: Option<String>,
#[sea_orm(column_name = "subscriptionTypes")]
pub subscription_types: Vec<PushSubscriptionType>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::access_token::Entity",
from = "Column::AppAccessTokenId",
to = "super::access_token::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
AccessToken,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
@ -33,6 +46,12 @@ pub enum Relation {
User,
}
impl Related<super::access_token::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccessToken.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()

View file

@ -68,6 +68,8 @@ pub struct Model {
pub is_indexable: bool,
#[sea_orm(column_name = "mutedPatterns")]
pub muted_patterns: Vec<String>,
#[sea_orm(column_type = "JsonBinary")]
pub mentions: Json,
#[sea_orm(column_name = "mutedInstances")]
pub muted_instances: Vec<String>,
#[sea_orm(column_name = "mutedWords")]

View file

@ -71,7 +71,7 @@ async fn add_note_to_antenna(antenna_id: &str, note: &Note) -> Result<(), Error>
.await?;
// for streaming API
stream::antenna::publish(antenna_id.to_string(), note).await?;
stream::antenna::publish(antenna_id.to_owned(), note).await?;
Ok(())
}

View file

@ -13,9 +13,9 @@ pub async fn watch_note(
note_watching::Entity::insert(note_watching::ActiveModel {
id: ActiveValue::set(gen_id_at(now)),
created_at: ActiveValue::set(now.into()),
user_id: ActiveValue::Set(watcher_id.to_string()),
note_user_id: ActiveValue::Set(note_author_id.to_string()),
note_id: ActiveValue::Set(note_id.to_string()),
user_id: ActiveValue::Set(watcher_id.to_owned()),
note_user_id: ActiveValue::Set(note_author_id.to_owned()),
note_id: ActiveValue::Set(note_id.to_owned()),
})
.exec(db_conn().await?)
.await?;

View file

@ -1,6 +1,12 @@
use crate::{
config::local_server_info, database::db_conn, misc::note::summarize,
model::entity::sw_subscription, util::http_client,
config::local_server_info,
database::db_conn,
misc::note::summarize,
model::entity::{access_token, app, sw_subscription},
util::{
http_client,
id::{get_timestamp, InvalidIdError},
},
};
use once_cell::sync::OnceCell;
use sea_orm::prelude::*;
@ -19,6 +25,11 @@ pub enum Error {
#[doc = "provided content is invalid"]
#[error("invalid content ({0})")]
InvalidContent(String),
#[doc = "found Mastodon subscription is invalid"]
#[error("invalid subscription ({0})")]
InvalidSubscription(String),
#[error("invalid notification ID")]
InvalidId(#[from] InvalidIdError),
#[error("failed to acquire an HTTP client")]
HttpClient(#[from] http_client::Error),
}
@ -44,7 +55,7 @@ pub enum PushNotificationKind {
fn compact_content(mut content: serde_json::Value) -> Result<serde_json::Value, Error> {
if !content.is_object() {
return Err(Error::InvalidContent("not a JSON object".to_string()));
return Err(Error::InvalidContent("not a JSON object".to_owned()));
}
let object = content.as_object_mut().unwrap();
@ -58,9 +69,7 @@ fn compact_content(mut content: serde_json::Value) -> Result<serde_json::Value,
.get("note")
.unwrap()
.get("renote")
.ok_or(Error::InvalidContent(
"renote object is missing".to_string(),
))?
.ok_or(Error::InvalidContent("renote object is missing".to_owned()))?
} else {
object.get("note").unwrap()
}
@ -68,7 +77,7 @@ fn compact_content(mut content: serde_json::Value) -> Result<serde_json::Value,
if !note.is_object() {
return Err(Error::InvalidContent(
"(re)note is not an object".to_string(),
"(re)note is not an object".to_owned(),
));
}
@ -90,12 +99,117 @@ fn compact_content(mut content: serde_json::Value) -> Result<serde_json::Value,
note_object.remove("reply");
note_object.remove("renote");
note_object.remove("user");
note_object.insert("text".to_string(), text.into());
object.insert("note".to_string(), note);
note_object.insert("text".to_owned(), text.into());
object.insert("note".to_owned(), note);
Ok(serde_json::from_value(Json::Object(object.clone()))?)
}
/// Returns a tuple containing the token and client name
async fn get_mastodon_subscription_info(
db: &DbConn,
subscription_id: &str,
token_id: &str,
) -> Result<(String, String), Error> {
let token = access_token::Entity::find()
.filter(access_token::Column::Id.eq(token_id))
.one(db)
.await?;
if token.is_none() {
unsubscribe(db, subscription_id).await?;
return Err(Error::InvalidSubscription(
"access token not found".to_owned(),
));
}
let token = token.unwrap();
if token.app_id.is_none() {
unsubscribe(db, subscription_id).await?;
return Err(Error::InvalidSubscription("no app ID".to_owned()));
}
let app_id = token.app_id.unwrap();
let client = app::Entity::find()
.filter(app::Column::Id.eq(app_id))
.one(db)
.await?;
if client.is_none() {
unsubscribe(db, subscription_id).await?;
return Err(Error::InvalidSubscription("app not found".to_owned()));
}
Ok((token.token, client.unwrap().name))
}
async fn encode_mastodon_payload(
mut content: serde_json::Value,
db: &DbConn,
subscription: &sw_subscription::Model,
) -> Result<String, Error> {
let object = content
.as_object_mut()
.ok_or(Error::InvalidContent("not a JSON object".to_owned()))?;
if subscription.app_access_token_id.is_none() {
unsubscribe(db, &subscription.id).await?;
return Err(Error::InvalidSubscription("no access token".to_owned()));
}
let (token, client_name) = get_mastodon_subscription_info(
db,
&subscription.id,
subscription.app_access_token_id.as_ref().unwrap(),
)
.await?;
object.insert("access_token".to_owned(), serde_json::to_value(token)?);
// Some apps expect notification_id to be an integer,
// but doesnt break when the ID doesnt match the rest of API.
if [
"IceCubesApp",
"Mammoth",
"feather",
"MaserApp",
"Metatext",
"Feditext",
]
.contains(&client_name.as_str())
{
let timestamp = object
.get("notification_id")
.and_then(|id| id.as_str())
.map(get_timestamp)
.transpose()?
.unwrap_or_default();
object.insert("notification_id".to_owned(), timestamp.into());
}
let res = serde_json::to_string(&content)?;
// Adding space paddings to the end of JSON payload to prevent
// `esm` from adding null bytes payload which many Mastodon clients dont support.
// https://firefish.dev/firefish/firefish/-/merge_requests/10905#note_6733
// not using the padding parameter directly on `res` because we want the padding to be
// calculated based on the UTF-8 byte size of `res` instead of number of characters.
let pad_length = match res.len() % 128 {
127 => 127,
n => 126 - n,
};
Ok(format!("{}{:pad_length$}", res, ""))
}
async fn unsubscribe(db: &DbConn, subscription_id: &str) -> Result<(), DbErr> {
sw_subscription::Entity::delete_by_id(subscription_id)
.exec(db)
.await?;
Ok(())
}
async fn handle_web_push_failure(
db: &DbConn,
err: WebPushError,
@ -114,9 +228,7 @@ async fn handle_web_push_failure(
| WebPushError::MissingCryptoKeys
| WebPushError::InvalidCryptoKeys
| WebPushError::InvalidResponse => {
sw_subscription::Entity::delete_by_id(subscription_id)
.exec(db)
.await?;
unsubscribe(db, subscription_id).await?;
tracing::info!("{}; {} was unsubscribed", error_message, subscription_id);
tracing::debug!("reason: {:#?}", err);
}
@ -157,9 +269,9 @@ pub async fn send_push_notification(
let use_mastodon_api = matches!(kind, PushNotificationKind::Mastodon);
// TODO: refactoring
let payload = if use_mastodon_api {
// Leave the `content` as it is
serde_json::to_string(content)?
let mut payload = if use_mastodon_api {
// Content generated per subscription
"".to_owned()
} else {
// Format the `content` passed from the TypeScript backend
// for Firefish push notifications
@ -206,6 +318,15 @@ pub async fn send_push_notification(
continue;
}
if use_mastodon_api {
if subscription.app_access_token_id.is_none() {
continue;
}
payload = encode_mastodon_payload(content.clone(), db, subscription).await?;
} else if subscription.app_access_token_id.is_some() {
continue;
}
let subscription_info = SubscriptionInfo {
endpoint: subscription.endpoint.to_owned(),
keys: SubscriptionKeys {
@ -246,7 +367,28 @@ pub async fn send_push_notification(
handle_web_push_failure(db, err, &subscription.id, "failed to build a payload").await?;
continue;
}
if let Err(err) = get_client()?.send(message.unwrap()).await {
// Ice Cubes cannot process ";rs=4096" at at the end of Encryption header
let mut message = message.unwrap();
if let Some(payload) = message.payload {
let crypto_headers: Vec<(&str, String)> = payload
.crypto_headers
.into_iter()
.map(|(key, val)| match key {
"Encryption" => (key, val.replace(";rs=4096", "")),
_ => (key, val),
})
.collect();
message.payload = Some(WebPushPayload {
content: payload.content,
content_encoding: payload.content_encoding,
crypto_headers,
});
}
if let Err(err) = get_client()?.send(message).await {
handle_web_push_failure(db, err, &subscription.id, "failed to send").await?;
continue;
}

View file

@ -80,13 +80,13 @@ pub async fn publish_to_stream(
value: Option<String>,
) -> Result<(), Error> {
let channel = match stream {
Stream::Internal => "internal".to_string(),
Stream::CustomEmoji => "broadcast".to_string(),
Stream::Internal => "internal".to_owned(),
Stream::CustomEmoji => "broadcast".to_owned(),
Stream::Moderation { moderator_id } => format!("adminStream:{moderator_id}"),
Stream::User { user_id } => format!("user:{user_id}"),
Stream::Channel { channel_id } => format!("channelStream:{channel_id}"),
Stream::Note { note_id } => format!("noteStream:{note_id}"),
Stream::Notes => "notesStream".to_string(),
Stream::Notes => "notesStream".to_owned(),
Stream::UserList { list_id } => format!("userListStream:{list_id}"),
Stream::Main { user_id } => format!("mainStream:{user_id}"),
Stream::Drive { user_id } => format!("driveStream:{user_id}"),
@ -103,7 +103,7 @@ pub async fn publish_to_stream(
format!(
"{{\"type\":\"{}\",\"body\":{}}}",
kind,
value.unwrap_or_else(|| "null".to_string()),
value.unwrap_or_else(|| "null".to_owned()),
)
} else {
value.ok_or(Error::InvalidContent)?

View file

@ -46,7 +46,7 @@ mod unit_test {
Err(InnerError1)
}
fn causes_inner_error_2() -> Result<(), InnerError2> {
Err(InnerError2("foo".to_string()))
Err(InnerError2("foo".to_owned()))
}
fn causes_error_1() -> Result<(), ErrorVariants> {

View file

@ -58,7 +58,7 @@ pub fn get_timestamp(id: &str) -> Result<i64, InvalidIdError> {
if let Some(n) = n {
Ok(n as i64 + TIME_2000)
} else {
Err(InvalidIdError { id: id.to_string() })
Err(InvalidIdError { id: id.to_owned() })
}
}

View file

@ -20,21 +20,23 @@
"format": "pnpm biome format * --write"
},
"dependencies": {
"@bull-board/api": "5.20.5",
"@bull-board/koa": "5.20.5",
"@bull-board/ui": "5.20.5",
"@bull-board/api": "5.21.0",
"@bull-board/koa": "5.21.0",
"@bull-board/ui": "5.21.0",
"@discordapp/twemoji": "15.0.3",
"@koa/cors": "5.0.0",
"@koa/multer": "3.0.2",
"@koa/router": "12.0.1",
"@ladjs/koa-views": "9.0.0",
"@peertube/http-signature": "1.7.0",
"@redocly/openapi-core": "1.17.1",
"@redocly/openapi-core": "1.18.0",
"@sinonjs/fake-timers": "11.2.2",
"adm-zip": "0.5.14",
"ajv": "8.16.0",
"ajv": "8.17.1",
"archiver": "7.0.1",
"aws-sdk": "2.1655.0",
"async-lock": "1.4.1",
"async-mutex": "0.5.0",
"aws-sdk": "2.1659.0",
"axios": "1.7.2",
"backend-rs": "workspace:*",
"blurhash": "2.0.5",
@ -51,7 +53,7 @@
"deepl-node": "1.13.0",
"escape-regexp": "0.0.1",
"feed": "4.2.2",
"file-type": "19.0.0",
"file-type": "19.1.1",
"firefish-js": "workspace:*",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.0",
@ -75,7 +77,6 @@
"koa-mount": "4.0.0",
"koa-remove-trailing-slashes": "2.0.3",
"koa-send": "5.0.1",
"megalodon": "workspace:*",
"mfm-js": "0.24.0",
"mime-types": "2.1.35",
"msgpackr": "1.10.2",
@ -93,7 +94,7 @@
"punycode": "2.3.1",
"pureimage": "0.4.13",
"qrcode": "1.5.3",
"qs": "6.12.2",
"qs": "6.12.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"redis-semaphore": "5.6.0",
@ -113,18 +114,20 @@
"tmp": "0.2.3",
"typeorm": "0.3.20",
"ulid": "2.3.0",
"unfurl.js": "6.4.0",
"uuid": "10.0.0",
"websocket": "1.0.35",
"xev": "3.0.2"
},
"devDependencies": {
"@types/adm-zip": "0.5.5",
"@types/async-lock": "1.4.2",
"@types/color-convert": "2.0.3",
"@types/content-disposition": "0.5.8",
"@types/escape-regexp": "0.0.3",
"@types/fluent-ffmpeg": "2.1.24",
"@types/jsdom": "21.1.7",
"@types/jsonld": "1.5.14",
"@types/jsonld": "1.5.15",
"@types/jsrsasign": "10.5.14",
"@types/katex": "0.16.7",
"@types/koa": "2.15.0",
@ -160,7 +163,7 @@
"@types/tmp": "0.2.6",
"@types/uuid": "10.0.0",
"@types/websocket": "1.0.10",
"@types/ws": "8.5.10",
"@types/ws": "8.5.11",
"cross-env": "7.0.3",
"mocha": "10.6.0",
"pug": "3.0.3",
@ -171,7 +174,7 @@
"tsconfig-paths": "4.2.0",
"type-fest": "4.21.0",
"typescript": "5.5.3",
"webpack": "5.92.1",
"webpack": "5.93.0",
"ws": "8.18.0"
}
}

View file

@ -0,0 +1,28 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class SwSubscriptionAccessToken1709395223612
implements MigrationInterface
{
name = "SwSubscriptionAccessToken1709395223612";
async up(queryRunner: QueryRunner) {
await queryRunner.query(
`ALTER TABLE "sw_subscription" ADD COLUMN IF NOT EXISTS "appAccessTokenId" character varying(32)`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" ADD CONSTRAINT "FK_98a1aa2db2a5253924f42f38767" FOREIGN KEY ("appAccessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`CREATE INDEX "IDX_98a1aa2db2a5253924f42f3876" ON "sw_subscription" ("appAccessTokenId") `,
);
}
async down(queryRunner: QueryRunner) {
await queryRunner.query(
`ALTER TABLE "sw_subscription" DROP CONSTRAINT "FK_98a1aa2db2a5253924f42f38767"`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" DROP COLUMN "appAccessTokenId"`,
);
}
}

View file

@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class UserProfileMentions1711075007936 implements MigrationInterface {
name = "UserProfileMentions1711075007936";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_profile" ADD "mentions" jsonb NOT NULL DEFAULT '[]'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_profile" DROP COLUMN "mentions"`,
);
}
}

View file

@ -1,459 +1,459 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class DropTimeZone1712425488543 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "abuse_user_report" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "access_token" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "access_token" ALTER "lastUsedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "ad" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "ad" ALTER "expiresAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement_read" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "antenna" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "app" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "attestation_challenge" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "auth_session" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "blocking" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel" ALTER "lastNotedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel_following" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel_note_pining" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "clip" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "drive_file" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "drive_folder" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "emoji" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "following" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "follow_request" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_like" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_post" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_post" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "caughtAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "infoUpdatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "lastCommunicatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "latestRequestReceivedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "latestRequestSentAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "moderation_log" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "muting" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "muting" ALTER "expiresAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_edit" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_favorite" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_reaction" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_thread_muting" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_watching" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "notification" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "page" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "page" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "page_like" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "password_reset_request" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "poll" ALTER "expiresAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "poll_vote" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "promo_note" ALTER "expiresAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "promo_read" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "registration_ticket" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "registry_item" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "registry_item" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "renote_muting" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "reply_muting" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "signin" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "used_username" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "lastActiveDate" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "lastFetchedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_invitation" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_invite" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_joining" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_ip" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_list" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_list_joining" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_note_pining" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_pending" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_security_key" ALTER "lastUsed" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "webhook" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "webhook" ALTER "latestSentAt" TYPE timestamp without time zone`,
);
public async up(_: QueryRunner): Promise<void> {
// await queryRunner.query(
// `ALTER TABLE "abuse_user_report" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "access_token" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "access_token" ALTER "lastUsedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "ad" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "ad" ALTER "expiresAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement_read" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "antenna" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "app" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "attestation_challenge" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "auth_session" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "blocking" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel" ALTER "lastNotedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel_following" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel_note_pining" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "clip" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "drive_file" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "drive_folder" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "emoji" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "following" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "follow_request" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_like" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_post" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_post" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "caughtAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "infoUpdatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "lastCommunicatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "latestRequestReceivedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "latestRequestSentAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "messaging_message" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "moderation_log" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "muting" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "muting" ALTER "expiresAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_edit" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_favorite" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_reaction" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_thread_muting" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_watching" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "notification" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page_like" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "password_reset_request" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "poll" ALTER "expiresAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "poll_vote" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "promo_note" ALTER "expiresAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "promo_read" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registration_ticket" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registry_item" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registry_item" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "renote_muting" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "reply_muting" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "signin" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "sw_subscription" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "used_username" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "lastActiveDate" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "lastFetchedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_invitation" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_invite" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_joining" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_ip" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_list" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_list_joining" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_note_pining" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_pending" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_security_key" ALTER "lastUsed" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "webhook" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "webhook" ALTER "latestSentAt" TYPE timestamp without time zone`,
// );
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "abuse_user_report" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "access_token" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "access_token" ALTER "lastUsedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "ad" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "ad" ALTER "expiresAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement" ALTER "updatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement_read" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "antenna" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "app" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "attestation_challenge" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "auth_session" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "blocking" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel" ALTER "lastNotedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel_following" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel_note_pining" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "clip" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "drive_file" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "drive_folder" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "emoji" ALTER "updatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "following" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "follow_request" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_like" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_post" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_post" ALTER "updatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "caughtAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "infoUpdatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "lastCommunicatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "latestRequestReceivedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "latestRequestSentAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "moderation_log" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "muting" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "muting" ALTER "expiresAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "note" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "note" ALTER "updatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_edit" ALTER "updatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_favorite" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_reaction" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_thread_muting" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_watching" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "notification" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "page" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "page" ALTER "updatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "page_like" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "password_reset_request" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "poll" ALTER "expiresAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "poll_vote" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "promo_note" ALTER "expiresAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "promo_read" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "registration_ticket" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "registry_item" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "registry_item" ALTER "updatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "renote_muting" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "reply_muting" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "signin" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "used_username" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "lastActiveDate" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "lastFetchedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "updatedAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_invitation" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_invite" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_joining" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_ip" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_list" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_list_joining" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_note_pining" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_pending" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_security_key" ALTER "lastUsed" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "webhook" ALTER "createdAt" TYPE timestamp with time zone`,
);
await queryRunner.query(
`ALTER TABLE "webhook" ALTER "latestSentAt" TYPE timestamp with time zone`,
);
public async down(_: QueryRunner): Promise<void> {
// await queryRunner.query(
// `ALTER TABLE "abuse_user_report" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "access_token" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "access_token" ALTER "lastUsedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "ad" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "ad" ALTER "expiresAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement" ALTER "updatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement_read" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "antenna" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "app" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "attestation_challenge" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "auth_session" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "blocking" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel" ALTER "lastNotedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel_following" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel_note_pining" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "clip" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "drive_file" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "drive_folder" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "emoji" ALTER "updatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "following" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "follow_request" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_like" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_post" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_post" ALTER "updatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "caughtAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "infoUpdatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "lastCommunicatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "latestRequestReceivedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "latestRequestSentAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "messaging_message" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "moderation_log" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "muting" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "muting" ALTER "expiresAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note" ALTER "updatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_edit" ALTER "updatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_favorite" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_reaction" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_thread_muting" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_watching" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "notification" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page" ALTER "updatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page_like" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "password_reset_request" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "poll" ALTER "expiresAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "poll_vote" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "promo_note" ALTER "expiresAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "promo_read" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registration_ticket" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registry_item" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registry_item" ALTER "updatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "renote_muting" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "reply_muting" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "signin" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "sw_subscription" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "used_username" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "lastActiveDate" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "lastFetchedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "updatedAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_invitation" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_invite" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_joining" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_ip" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_list" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_list_joining" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_note_pining" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_pending" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_security_key" ALTER "lastUsed" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "webhook" ALTER "createdAt" TYPE timestamp with time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "webhook" ALTER "latestSentAt" TYPE timestamp with time zone`,
// );
}
}

View file

@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class ClientCredentials1713108561474 implements MigrationInterface {
name = "ClientCredentials1713108561474";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "access_token" ALTER COLUMN "userId" DROP NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "access_token" ALTER COLUMN "userId" SET NOT NULL`,
);
}
}

View file

@ -0,0 +1,28 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class AddMastodonSubscriptionType1715181461692
implements MigrationInterface
{
name = "AddMastodonSubscriptionType1715181461692";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "push_subscription_type" AS ENUM ('mention', 'status', 'reblog', 'follow', 'follow_request', 'favourite', 'poll', 'update', 'admin.sign_up', 'admin.report')`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" ADD "subscriptionTypes" push_subscription_type array NOT NULL DEFAULT '{}'`,
);
await queryRunner.query(`
UPDATE "sw_subscription"
SET "subscriptionTypes" = ARRAY['mention', 'status', 'reblog', 'follow', 'follow_request', 'favourite', 'poll', 'update']::push_subscription_type[]
WHERE "appAccessTokenId" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "sw_subscription" DROP COLUMN "subscriptionTypes"`,
);
await queryRunner.query(`DROP TYPE "push_subscription_type"`);
}
}

View file

@ -229,231 +229,231 @@ export class AddBackTimezone1715351290096 implements MigrationInterface {
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "abuse_user_report" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "access_token" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "access_token" ALTER "lastUsedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "ad" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "ad" ALTER "expiresAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "announcement_read" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "antenna" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "app" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "attestation_challenge" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "auth_session" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "blocking" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel" ALTER "lastNotedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel_following" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "channel_note_pining" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "clip" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "drive_file" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "drive_folder" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "emoji" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "following" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "follow_request" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_like" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_post" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "gallery_post" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "caughtAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "infoUpdatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "lastCommunicatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "latestRequestReceivedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "instance" ALTER "latestRequestSentAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "messaging_message" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "moderation_log" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "muting" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "muting" ALTER "expiresAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_edit" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_favorite" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_reaction" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_thread_muting" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "note_watching" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "notification" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "page" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "page" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "page_like" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "password_reset_request" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "poll" ALTER "expiresAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "poll_vote" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "promo_note" ALTER "expiresAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "promo_read" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "registration_ticket" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "registry_item" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "registry_item" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "renote_muting" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "reply_muting" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "signin" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "sw_subscription" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "used_username" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "lastActiveDate" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "lastFetchedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user" ALTER "updatedAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_invitation" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_invite" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_group_joining" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_ip" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_list" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_list_joining" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_note_pining" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_pending" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "user_security_key" ALTER "lastUsed" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "webhook" ALTER "createdAt" TYPE timestamp without time zone`,
);
await queryRunner.query(
`ALTER TABLE "webhook" ALTER "latestSentAt" TYPE timestamp without time zone`,
);
public async down(_: QueryRunner): Promise<void> {
// await queryRunner.query(
// `ALTER TABLE "abuse_user_report" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "access_token" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "access_token" ALTER "lastUsedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "ad" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "ad" ALTER "expiresAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "announcement_read" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "antenna" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "app" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "attestation_challenge" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "auth_session" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "blocking" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel" ALTER "lastNotedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel_following" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "channel_note_pining" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "clip" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "drive_file" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "drive_folder" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "emoji" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "following" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "follow_request" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_like" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_post" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "gallery_post" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "caughtAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "infoUpdatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "lastCommunicatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "latestRequestReceivedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "instance" ALTER "latestRequestSentAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "messaging_message" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "moderation_log" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "muting" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "muting" ALTER "expiresAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_edit" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_favorite" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_reaction" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_thread_muting" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "note_watching" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "notification" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "page_like" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "password_reset_request" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "poll" ALTER "expiresAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "poll_vote" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "promo_note" ALTER "expiresAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "promo_read" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registration_ticket" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registry_item" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "registry_item" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "renote_muting" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "reply_muting" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "signin" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "sw_subscription" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "used_username" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "lastActiveDate" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "lastFetchedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user" ALTER "updatedAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_invitation" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_invite" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_group_joining" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_ip" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_list" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_list_joining" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_note_pining" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_pending" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "user_security_key" ALTER "lastUsed" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "webhook" ALTER "createdAt" TYPE timestamp without time zone`,
// );
// await queryRunner.query(
// `ALTER TABLE "webhook" ALTER "latestSentAt" TYPE timestamp without time zone`,
// );
}
}

View file

@ -0,0 +1,76 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
import { v4 as uuid } from "uuid";
import { genRsaKeyPair } from "@/misc/gen-key-pair.js";
import { generateUserToken, genIdAt, hashPassword } from "backend-rs";
async function createSystemUser(username: string, queryRunner: QueryRunner) {
const password = uuid();
// Generate hash of password
const hash = hashPassword(password);
// Generate secret
const secret = generateUserToken();
const keyPair = await genRsaKeyPair(4096);
const existsQuery = await queryRunner.query(
`SELECT "usernameLower" FROM "user" WHERE "usernameLower" = $1 AND "host" IS NULL`,
[username.toLowerCase()],
);
if (existsQuery.length) {
return;
}
const now = new Date();
await queryRunner.query(
`INSERT INTO "user" (
"id", "createdAt", "username", "usernameLower", "host", "token",
"isAdmin", "isLocked", "isExplorable", "isBot"
)
VALUES (
$1, $2, $3, $4, NULL,
$5, FALSE, TRUE, FALSE, TRUE
)`,
[genIdAt(now), now, username, username.toLowerCase(), secret],
);
const account = await queryRunner.query(
`SELECT * FROM "user" WHERE "usernameLower" = $1 AND "host" IS NULL`,
[username.toLowerCase()],
);
if (!account.length) {
throw new Error("Account not found");
}
await queryRunner.query(
`INSERT INTO "user_keypair" ("publicKey", "privateKey", "userId")
VALUES ($1, $2, $3)`,
[keyPair.publicKey, keyPair.privateKey, account[0].id],
);
await queryRunner.query(
`INSERT INTO "user_profile" ("userId", "autoAcceptFollowed", "password")
VALUES ($1, FALSE, $2)`,
[account[0].id, hash],
);
await queryRunner.query(
`INSERT INTO "used_username" ("createdAt", "username")
VALUES ($1, $2)`,
[now, username.toLowerCase()],
);
}
export class CreateSystemActors1720618854585 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await createSystemUser("instance.actor", queryRunner);
await createSystemUser("relay.actor", queryRunner);
}
public async down(_: QueryRunner): Promise<void> {
/* You don't need to revert this migration. */
}
}

View file

@ -0,0 +1,36 @@
import type { User } from "@/models/entities/user.js";
import type { Note } from "@/models/entities/note.js";
import type { UserProfile } from "@/models/entities/user-profile.js";
import { checkWordMute } from "backend-rs";
import { Cache } from "@/misc/cache.js";
import { UserProfiles } from "@/models/index.js";
const filteredNoteCache = new Cache<boolean>("filteredNote", 60 * 60 * 24);
const mutedWordsCache = new Cache<
Pick<UserProfile, "mutedWords" | "mutedPatterns">
>("mutedWords", 60 * 5);
export async function isFiltered(
note: Note,
user: { id: User["id"] } | null | undefined,
): Promise<boolean> {
if (!user) return false;
const profile = await mutedWordsCache.fetch(user.id, () =>
UserProfiles.findOneBy({ userId: user.id }).then((p) => ({
mutedWords: p?.mutedWords ?? [],
mutedPatterns: p?.mutedPatterns ?? [],
})),
);
if (
!profile ||
(profile.mutedPatterns.length < 1 && profile.mutedWords.length < 1)
)
return false;
const ts = (note.updatedAt ?? note.createdAt) as Date | string;
const identifier =
(typeof ts === "string" ? new Date(ts) : ts)?.getTime() ?? "0";
return filteredNoteCache.fetch(`${note.id}:${identifier}:${user.id}`, () =>
checkWordMute(note, profile.mutedWords, profile.mutedPatterns),
);
}

View file

@ -1,7 +1,12 @@
import type { Packed } from "./schema.js";
type NoteWithUserHost = { user: { host: string | null } | null };
export function isInstanceMuted(
note: Packed<"Note">,
note: NoteWithUserHost & {
reply: NoteWithUserHost | null;
renote: NoteWithUserHost | null;
},
mutedInstances: Set<string>,
): boolean {
if (mutedInstances.has(note?.user?.host ?? "")) return true;

View file

@ -14,7 +14,7 @@ const cache = new Cache<Emoji | null>("populateEmojis", 60 * 60 * 12);
/**
*
*/
type PopulatedEmoji = {
export type PopulatedEmoji = {
name: string;
url: string;
width: number | null;

View file

@ -0,0 +1,90 @@
import { Notes } from "@/models/index.js";
import type { Note } from "@/models/entities/note.js";
import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js";
import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js";
import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js";
import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js";
import type { SelectQueryBuilder } from "typeorm";
export interface SearchParams {
withFilesOnly: boolean;
limit: number;
myId: string | undefined;
sinceId: string | undefined;
untilId: string | undefined;
sinceDate: number | undefined;
untilDate: number | undefined;
userId: string | null | undefined;
channelId: string | null;
host: string | null | undefined;
}
export async function searchNotes(
params: SearchParams,
modifier?: (query: SelectQueryBuilder<Note>) => void,
): Promise<Note[]> {
const query = makePaginationQuery(
Notes.createQueryBuilder("note"),
params.sinceId ?? undefined,
params.untilId ?? undefined,
params.sinceDate ?? undefined,
params.untilDate ?? undefined,
);
modifier?.(query);
if (params.userId != null) {
query.andWhere("note.userId = :userId", { userId: params.userId });
}
if (params.channelId != null) {
query.andWhere("note.channelId = :channelId", {
channelId: params.channelId,
});
}
query.innerJoinAndSelect("note.user", "user");
// "from: me": search all (public, home, followers, specified) my posts
// otherwise: search public indexable posts only
if (params.userId == null || params.userId !== params.myId) {
query
.andWhere("note.visibility = 'public'")
.andWhere("user.isIndexable = TRUE");
}
if (params.userId != null) {
query.andWhere("note.userId = :userId", { userId: params.userId });
}
if (params.host === null) {
// search local notes only
query.andWhere("note.userHost IS NULL");
}
if (params.host != null) {
query.andWhere("note.userHost = :userHost", { userHost: params.host });
}
if (params.withFilesOnly) {
query.andWhere("note.fileIds != '{}'");
}
query
.leftJoinAndSelect("user.avatar", "avatar")
.leftJoinAndSelect("user.banner", "banner")
.leftJoinAndSelect("note.reply", "reply")
.leftJoinAndSelect("note.renote", "renote")
.leftJoinAndSelect("reply.user", "replyUser")
.leftJoinAndSelect("replyUser.avatar", "replyUserAvatar")
.leftJoinAndSelect("replyUser.banner", "replyUserBanner")
.leftJoinAndSelect("renote.user", "renoteUser")
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
const me = params.myId != null ? { id: params.myId } : null;
generateVisibilityQuery(query, me);
if (params.myId != null) generateMutedUserQuery(query, { id: params.myId });
if (params.myId != null) generateBlockedUserQuery(query, { id: params.myId });
return await query.take(params.limit).getMany();
}

View file

@ -46,8 +46,11 @@ export class AccessToken {
public hash: string;
@Index()
@Column(id())
public userId: User["id"];
@Column({
...id(),
nullable: true,
})
public userId: User["id"] | null;
@Column({
...id(),

View file

@ -312,9 +312,11 @@ export class Note {
}
}
export type IMentionedRemoteUsers = {
export type IMentionedRemoteUser = {
uri: string;
url?: string;
username: string;
host: string;
}[];
};
export type IMentionedRemoteUsers = IMentionedRemoteUser[];

View file

@ -9,6 +9,23 @@ import {
} from "typeorm";
import { User } from "./user.js";
import { id } from "../id.js";
import { AccessToken } from "./access-token.js";
// for Mastodon push notifications
const pushSubscriptionTypes = [
"mention",
"status",
"reblog",
"follow",
"follow_request",
"favourite",
"poll",
"update",
"admin.sign_up",
"admin.report",
] as const;
type pushSubscriptionType = (typeof pushSubscriptionTypes)[number];
@Entity()
export class SwSubscription {
@ -42,11 +59,40 @@ export class SwSubscription {
})
public sendReadMessage: boolean;
/**
* Type of subscription, used for Mastodon API notifications.
* Empty for Misskey notifications.
*/
@Column({
type: "enum",
enum: pushSubscriptionTypes,
array: true,
default: "{}",
})
public subscriptionTypes: pushSubscriptionType[];
/**
* App notification app, used for Mastodon API notifications
*/
@Index()
@Column({
...id(),
nullable: true,
})
public appAccessTokenId: AccessToken["id"] | null;
//#region Relations
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
@JoinColumn()
public user: Relation<User>;
@ManyToOne(() => AccessToken, {
onDelete: "CASCADE",
nullable: true,
})
@JoinColumn()
public appAccessToken: Relation<AccessToken | null>;
//#endregion
}

View file

@ -11,6 +11,7 @@ import { ffVisibility, notificationTypes } from "@/types.js";
import { id } from "../id.js";
import { User } from "./user.js";
import { Page } from "./page.js";
import type { IMentionedRemoteUsers } from "./note.js";
// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
@ -50,6 +51,11 @@ export class UserProfile {
verified?: boolean;
}[];
@Column("jsonb", {
default: [],
})
public mentions: IMentionedRemoteUsers;
@Column("varchar", {
length: 32,
nullable: true,

View file

@ -37,7 +37,6 @@ import { AppRepository } from "./repositories/app.js";
import { FollowingRepository } from "./repositories/following.js";
import { AbuseUserReportRepository } from "./repositories/abuse-user-report.js";
import { AuthSessionRepository } from "./repositories/auth-session.js";
import { UserProfile } from "./entities/user-profile.js";
import { AttestationChallenge } from "./entities/attestation-challenge.js";
import { UserSecurityKey } from "./entities/user-security-key.js";
import { HashtagRepository } from "./repositories/hashtag.js";
@ -67,6 +66,7 @@ import { Webhook } from "./entities/webhook.js";
import { UserIp } from "./entities/user-ip.js";
import { NoteFileRepository } from "./repositories/note-file.js";
import { NoteEditRepository } from "./repositories/note-edit.js";
import { UserProfileRepository } from "./repositories/user-profile.js";
export const Announcements = db.getRepository(Announcement);
export const AnnouncementReads = db.getRepository(AnnouncementRead);
@ -82,7 +82,7 @@ export const NoteUnreads = db.getRepository(NoteUnread);
export const Polls = db.getRepository(Poll);
export const PollVotes = db.getRepository(PollVote);
export const Users = UserRepository;
export const UserProfiles = db.getRepository(UserProfile);
export const UserProfiles = UserProfileRepository;
export const UserKeypairs = db.getRepository(UserKeypair);
export const UserPendings = db.getRepository(UserPending);
export const AttestationChallenges = db.getRepository(AttestationChallenge);

View file

@ -8,6 +8,7 @@ import { config } from "@/config.js";
import { query, appendQuery } from "@/prelude/url.js";
import { Users, DriveFolders } from "../index.js";
import { deepClone } from "@/misc/clone.js";
import { fetchMeta } from "backend-rs";
type PackOptions = {
detail?: boolean;
@ -221,4 +222,33 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
);
return items.filter((x): x is Packed<"DriveFile"> => x != null);
},
async getFinalUrl(url: string): Promise<string> {
if (!config.proxyRemoteFiles) return url;
if (!url.startsWith("https://") && !url.startsWith("http://")) return url;
if (url.startsWith(`${config.url}/files`)) return url;
if (url.startsWith(`${config.url}/static-assets`)) return url;
if (url.startsWith(`${config.url}/identicon`)) return url;
if (url.startsWith(`${config.url}/avatar`)) return url;
const meta = await fetchMeta();
const baseUrl = meta
? meta.objectStorageBaseUrl ??
`${meta.objectStorageUseSsl ? "https" : "http"}://${
meta.objectStorageEndpoint
}${meta.objectStoragePort ? `:${meta.objectStoragePort}` : ""}/${
meta.objectStorageBucket
}`
: null;
if (baseUrl !== null && url.startsWith(baseUrl)) return url;
return `${config.url}/proxy/${encodeURIComponent(
new URL(url).pathname,
)}?${query({ url: url })}`;
},
async getFinalUrlMaybe(url?: string | null): Promise<string | null> {
if (url == null) return null;
return this.getFinalUrl(url);
},
});

View file

@ -10,6 +10,7 @@ import {
Followings,
Polls,
Channels,
UserProfiles,
Notes,
} from "../index.js";
import type { Packed } from "@/misc/schema.js";
@ -28,6 +29,7 @@ import {
} from "@/misc/populate-emojis.js";
import { db } from "@/db/postgre.js";
import { IdentifiableError } from "@/misc/identifiable-error.js";
import { config } from "@/config.js";
export async function populatePoll(note: Note, meId: User["id"] | null) {
const poll = await Polls.findOneByOrFail({ noteId: note.id });
@ -150,6 +152,29 @@ export const NoteRepository = db.getRepository(Note).extend({
return true;
},
async mentionedRemoteUsers(note: Note): Promise<string | undefined> {
if (note.mentions?.length) {
const mentionedUserIds = [...new Set(note.mentions)].sort();
const mentionedUsers = await Users.findBy({
id: In(mentionedUserIds),
});
const userProfiles = await UserProfiles.findBy({
userId: In(mentionedUserIds),
});
return JSON.stringify(
mentionedUsers.map((u) => ({
username: u.username,
host: u.host ?? config.host,
uri: u.uri ?? `${config.url}/users/${u.id}`,
url:
userProfiles.find((p) => p.userId === u.id)?.url ??
`${config.url}/@${u.username}`,
})),
);
}
return undefined;
},
async pack(
src: Note["id"] | Note,
me?: { id: User["id"] } | null | undefined,
@ -288,6 +313,7 @@ export const NoteRepository = db.getRepository(Note).extend({
}
: {}),
lang: note.lang,
mentionedRemoteUsers: this.mentionedRemoteUsers(note),
});
if (

View file

@ -0,0 +1,87 @@
import { db } from "@/db/postgre.js";
import { UserProfile } from "@/models/entities/user-profile.js";
import mfm from "mfm-js";
import { extractMentions } from "@/misc/extract-mentions.js";
import {
type ProfileMention,
resolveMentionToUserAndProfile,
} from "@/remote/resolve-user.js";
import type {
IMentionedRemoteUser,
IMentionedRemoteUsers,
} from "@/models/entities/note.js";
import { unique } from "@/prelude/array.js";
import { config } from "@/config.js";
import { Mutex, Semaphore } from "async-mutex";
const queue = new Semaphore(5);
export const UserProfileRepository = db.getRepository(UserProfile).extend({
// We must never await this without promiseEarlyReturn, otherwise giant webring-style profile mention trees will cause the queue to stop working
async updateMentions(
id: UserProfile["userId"],
limiter: RecursionLimiter = new RecursionLimiter(),
) {
const profile = await this.findOneBy({ userId: id });
if (!profile) return;
const tokens: mfm.MfmNode[] = [];
if (profile.description) tokens.push(...mfm.parse(profile.description));
if (profile.fields.length > 0)
tokens.push(
...profile.fields.flatMap((p) =>
mfm.parse(p.value).concat(mfm.parse(p.name)),
),
);
return queue.runExclusive(async () => {
const partial = {
mentions: await populateMentions(tokens, profile.userHost, limiter),
};
return UserProfileRepository.update(profile.userId, partial);
});
},
});
async function populateMentions(
tokens: mfm.MfmNode[],
objectHost: string | null,
limiter: RecursionLimiter,
): Promise<IMentionedRemoteUsers> {
const mentions = extractMentions(tokens);
const resolved = await Promise.all(
mentions.map((m) =>
resolveMentionToUserAndProfile(m.username, m.host, objectHost, limiter),
),
);
const remote = resolved.filter(
(p): p is ProfileMention =>
!!p &&
p.data.host !== config.host &&
(p.data.host !== null || objectHost !== null),
);
const res = remote.map((m) => {
return {
uri: m.user.uri,
url: m.profile?.url ?? undefined,
username: m.data.username,
host: m.data.host,
} as IMentionedRemoteUser;
});
return unique(res);
}
export class RecursionLimiter {
private counter;
private mutex = new Mutex();
constructor(count = 10) {
this.counter = count;
}
public shouldContinue(): Promise<boolean> {
return this.mutex.runExclusive(() => {
return this.counter-- > 0;
});
}
}

View file

@ -51,6 +51,17 @@ export function unique<T>(xs: T[]): T[] {
return [...new Set(xs)];
}
/**
* Filters an array of elements based on unique outputs of a key function
*/
export function uniqBy<T, U>(a: T[], key: (elm: T) => U): T[] {
const seen = new Set<U>();
return a.filter((item) => {
const k = key(item);
return seen.has(k) ? false : seen.add(k);
});
}
export function sum(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0);
}
@ -150,3 +161,7 @@ export function toArray<T>(x: T | T[] | undefined): T[] {
export function toSingle<T>(x: T | T[] | undefined): T | undefined {
return Array.isArray(x) ? x[0] : x;
}
export function toSingleLast<T>(x: T | T[] | undefined): T | undefined {
return Array.isArray(x) ? x.at(-1) : x;
}

View file

@ -4,7 +4,27 @@ export type Promiseable<T> = {
[K in keyof T]: Promise<T[K]> | T[K];
};
export async function awaitAll<T>(obj: Promiseable<T>): Promise<T> {
type RecursiveResolvePromise<U> = U extends Date
? U
: U extends Array<infer V>
? Array<ResolvedPromise<V>>
: U extends object
? { [key in keyof U]: ResolvedPromise<U[key]> }
: U;
type ResolvedPromise<T> = T extends Promise<infer U>
? RecursiveResolvePromise<U>
: RecursiveResolvePromise<T>;
export type OuterPromise<T> = Promise<{
[K in keyof T]: ResolvedPromise<T[K]>;
}>;
/**
* Resolve all promises in the object recursively,
* and return a promise that resolves to the object with all promises resolved.
*/
export async function awaitAll<T>(obj: Promiseable<T>): OuterPromise<T> {
const target = {} as T;
const keys = unsafeCast<(keyof T)[]>(Object.keys(obj));
const values = Object.values(obj) as any[];
@ -21,5 +41,5 @@ export async function awaitAll<T>(obj: Promiseable<T>): Promise<T> {
target[keys[i]] = resolvedValues[i];
}
return target;
return target as OuterPromise<T>;
}

View file

@ -0,0 +1,13 @@
/**
* Returns T if promise settles before timeout,
* otherwise returns void, finishing execution in the background.
*/
export async function promiseEarlyReturn<T>(
promise: Promise<T>,
after: number,
): Promise<T | void> {
const timer: Promise<void> = new Promise((res) =>
setTimeout(() => res(undefined), after),
);
return Promise.race([promise, timer]);
}

View file

@ -1,8 +1,8 @@
import { config } from "@/config.js";
import type { ILocalUser } from "@/models/entities/user.js";
import { getInstanceActor } from "@/services/instance-actor.js";
import {
extractHost,
getInternalActor,
isAllowedServer,
isBlockedServer,
isSelfHost,
@ -112,7 +112,7 @@ export default class Resolver {
}
if (!this.user) {
this.user = await getInstanceActor();
this.user = await getInternalActor("instance");
}
apLogger.info(
@ -158,7 +158,7 @@ export default class Resolver {
if (!parsed.local) throw new Error("resolveLocal: not local");
switch (parsed.type) {
case "notes":
case "notes": {
const note = await Notes.findOneByOrFail({ id: parsed.id });
if (parsed.rest === "activity") {
// this refers to the create activity and not the note itself
@ -166,20 +166,24 @@ export default class Resolver {
} else {
return renderNote(note);
}
case "users":
}
case "users": {
const user = await Users.findOneByOrFail({ id: parsed.id });
return await renderPerson(user as ILocalUser);
case "questions":
}
case "questions": {
// Polls are indexed by the note they are attached to.
const [pollNote, poll] = await Promise.all([
Notes.findOneByOrFail({ id: parsed.id }),
Polls.findOneByOrFail({ noteId: parsed.id }),
]);
return await renderQuestion({ id: pollNote.userId }, pollNote, poll);
case "likes":
}
case "likes": {
const reaction = await NoteReactions.findOneByOrFail({ id: parsed.id });
return renderActivity(renderLike(reaction, { uri: null }));
case "follows":
}
case "follows": {
// if rest is a <followee id>
if (parsed.rest != null && /^\w+$/.test(parsed.rest)) {
const [follower, followee] = await Promise.all(
@ -207,6 +211,7 @@ export default class Resolver {
throw new Error("resolveLocal: invalid follow URI");
}
return renderActivity(renderFollow(follower, followee, url));
}
default:
throw new Error(`resolveLocal: type ${parsed.type} unhandled`);
}

View file

@ -3,21 +3,53 @@ import chalk from "chalk";
import { IsNull } from "typeorm";
import { config } from "@/config.js";
import type { User, IRemoteUser } from "@/models/entities/user.js";
import { Users } from "@/models/index.js";
import { Cache } from "@/misc/cache.js";
import { UserProfiles, Users } from "@/models/index.js";
import { toPuny } from "backend-rs";
import webFinger from "./webfinger.js";
import { createPerson, updatePerson } from "./activitypub/models/person.js";
import { remoteLogger } from "./logger.js";
import { inspect } from "node:util";
import type { UserProfile } from "@/models/entities/user-profile.js";
import { RecursionLimiter } from "@/models/repositories/user-profile.js";
import { promiseEarlyReturn } from "@/prelude/promise.js";
import type { IMentionedRemoteUsers } from "@/models/entities/note.js";
const logger = remoteLogger.createSubLogger("resolve-user");
const localUsernameCache = new Cache<string | null>(
"localUserNameCapitalization",
60 * 60 * 24,
);
const profileMentionCache = new Cache<ProfileMention | null>(
"resolveProfileMentions",
60 * 60,
);
export type ProfileMention = {
user: User;
profile: UserProfile | null;
data: {
username: string;
host: string | null;
};
};
type refreshType =
| "refresh"
| "refresh-in-background"
| "refresh-timeout-1500ms"
| "no-refresh";
export async function resolveUser(
username: string,
host: string | null,
refresh: refreshType = "refresh",
limiter: RecursionLimiter = new RecursionLimiter(),
): Promise<User> {
const usernameLower = username.toLowerCase();
// Return local user if host part is empty
if (host == null) {
logger.info(`return local user: ${usernameLower}`);
return await Users.findOneBy({ usernameLower, host: IsNull() }).then(
@ -33,7 +65,9 @@ export async function resolveUser(
host = toPuny(host);
if (config.host === host) {
// Also return local user if host part is specified but referencing the local instance
if (config.host === host || config.host === host) {
logger.info(`return local user: ${usernameLower}`);
return await Users.findOneBy({ usernameLower, host: IsNull() }).then(
(u) => {
@ -46,24 +80,63 @@ export async function resolveUser(
);
}
const user = (await Users.findOneBy({
// Check if remote user is already in the database
let user = (await Users.findOneBy({
usernameLower,
host,
})) as IRemoteUser | null;
const acctLower = `${usernameLower}@${host}`;
if (user == null) {
const self = await resolveSelf(acctLower);
// If not, look up the user on the remote server
logger.info(`return new remote user: ${chalk.magenta(acctLower)}`);
return await createPerson(self.href);
if (user == null) {
// Run WebFinger
const fingerRes = await resolveUserWebFinger(acctLower);
const finalAcct = subjectToAcct(fingerRes.subject);
const finalAcctLower = finalAcct.toLowerCase();
const m = finalAcct.match(/^([^@]+)@(.*)/);
const subjectHost = m ? m[2] : undefined;
// If subject is different, we're dealing with a split domain setup (that's already been validated by resolveUserWebFinger)
if (acctLower !== finalAcctLower) {
logger.info("re-resolving split domain redirect user...");
const m = finalAcct.match(/^([^@]+)@(.*)/);
if (m) {
// Re-check if we already have the user in the database post-redirect
user = (await Users.findOneBy({
usernameLower: usernameLower,
host: subjectHost,
})) as IRemoteUser | null;
// If yes, return existing user
if (user != null) {
logger.info(
`return existing remote user: ${chalk.magenta(finalAcctLower)}`,
);
return user;
}
// Otherwise create and return new user
else {
logger.info(
`return new remote user: ${chalk.magenta(finalAcctLower)}`,
);
return await createPerson(fingerRes.self.href);
}
}
}
// Not a split domain setup, so we can simply create and return the new user
logger.info(`return new remote user: ${chalk.magenta(finalAcctLower)}`);
return await createPerson(fingerRes.self.href);
}
// If user information is out of date, return it by starting over from WebFilger
// If user information is out of date, return it by starting over from WebFinger
if (
user.lastFetchedAt == null ||
Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24
(refresh === "refresh" || refresh === "refresh-timeout-1500ms") &&
(user.lastFetchedAt == null ||
Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24)
) {
// Prevent multiple attempts to connect to unconnected instances, update before each attempt to prevent subsequent similar attempts
await Users.update(user.id, {
@ -71,17 +144,17 @@ export async function resolveUser(
});
logger.info(`try resync: ${acctLower}`);
const self = await resolveSelf(acctLower);
const fingerRes = await resolveUserWebFinger(acctLower);
if (user.uri !== self.href) {
if (user.uri !== fingerRes.self.href) {
// if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping.
logger.info(`uri missmatch: ${acctLower}`);
logger.info(
`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`,
`recovery mismatch uri for (username=${username}, host=${host}) from ${user.uri} to ${fingerRes.self.href}`,
);
// validate uri
const uri = new URL(self.href);
const uri = new URL(fingerRes.self.href);
if (uri.hostname !== host) {
throw new Error("Invalid uri");
}
@ -92,23 +165,60 @@ export async function resolveUser(
host: host,
},
{
uri: self.href,
uri: fingerRes.self.href,
},
);
} else {
logger.info(`uri is fine: ${acctLower}`);
}
await updatePerson(self.href);
const finalAcct = subjectToAcct(fingerRes.subject);
const finalAcctLower = finalAcct.toLowerCase();
const m = finalAcct.match(/^([^@]+)@(.*)/);
const finalHost = m ? m[2] : null;
logger.info(`return resynced remote user: ${acctLower}`);
return await Users.findOneBy({ uri: self.href }).then((u) => {
// Update user.host if we're dealing with an account that's part of a split domain setup that hasn't been fixed yet
if (m && user.host !== finalHost) {
logger.info(
`updating user host to subject acct host: ${user.host} -> ${finalHost}`,
);
await Users.update(
{
usernameLower,
host: user.host,
},
{
host: finalHost,
},
);
}
if (refresh === "refresh") {
await updatePerson(fingerRes.self.href);
logger.info(`return resynced remote user: ${finalAcctLower}`);
} else if (refresh === "refresh-timeout-1500ms") {
const res = await promiseEarlyReturn(
updatePerson(fingerRes.self.href),
1500,
);
logger.info(`return possibly resynced remote user: ${finalAcctLower}`);
}
return await Users.findOneBy({ uri: fingerRes.self.href }).then((u) => {
if (u == null) {
throw new Error("user not found");
} else {
return u;
}
});
} else if (
refresh === "refresh-in-background" &&
(user.lastFetchedAt == null ||
Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24)
) {
// Run the refresh in the background
// noinspection ES6MissingAwait
resolveUser(username, host, "refresh", limiter);
}
logger.info(`return existing remote user: ${acctLower}`);
@ -136,3 +246,185 @@ async function resolveSelf(acctLower: string) {
}
return self;
}
async function getLocalUsernameCached(
username: string,
): Promise<string | null> {
return localUsernameCache.fetch(username.toLowerCase(), () =>
Users.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull(),
}).then((p) => (p ? p.username : null)),
);
}
export function getMentionFallbackUri(
username: string,
host: string | null,
objectHost: string | null,
): string {
let fallback = `${config.url}/@${username}`;
if (host !== null && host !== config.host) fallback += `@${host}`;
else if (
objectHost !== null &&
objectHost !== config.host &&
host !== config.host
)
fallback += `@${objectHost}`;
return fallback;
}
export async function resolveMentionFromCache(
username: string,
host: string | null,
objectHost: string | null,
cache: IMentionedRemoteUsers,
): Promise<{
username: string;
href: string;
host: string;
isLocal: boolean;
} | null> {
const isLocal =
(host === null && objectHost === null) || host === config.host;
if (isLocal) {
const finalUsername = await getLocalUsernameCached(username);
if (finalUsername === null) return null;
username = finalUsername;
}
const fallback = getMentionFallbackUri(username, host, objectHost);
const cached = cache.find(
(r) =>
r.username.toLowerCase() === username.toLowerCase() &&
r.host === (host ?? objectHost),
);
const href = cached?.url ?? cached?.uri;
if (cached && href != null)
return {
username: cached.username,
href: href,
isLocal,
host: cached.host,
};
if (isLocal)
return { username: username, href: fallback, isLocal, host: config.host };
return null;
}
export async function resolveMentionToUserAndProfile(
username: string,
host: string | null,
objectHost: string | null,
limiter: RecursionLimiter,
) {
return profileMentionCache.fetch(
`${username}@${host ?? objectHost}`,
async () => {
try {
const user = await resolveUser(
username,
host ?? objectHost,
"no-refresh",
limiter,
);
const profile = await UserProfiles.findOneBy({ userId: user.id });
const data = { username, host: host ?? objectHost };
return { user, profile, data };
} catch {
return null;
}
},
);
}
async function resolveUserWebFinger(
acctLower: string,
recurse = true,
): Promise<{
subject: string;
self: {
href: string;
rel?: string;
};
}> {
logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
const fingerRes = await webFinger(acctLower).catch((e) => {
logger.error(
`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${
e.statusCode || e.message
}`,
);
throw new Error(
`Failed to WebFinger for ${acctLower}: ${e.statusCode || e.message}`,
);
});
const self = fingerRes.links.find(
(link) => link.rel != null && link.rel.toLowerCase() === "self",
);
if (!self) {
logger.error(
`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`,
);
throw new Error("self link not found");
}
if (`${acctToSubject(acctLower)}` !== normalizeSubject(fingerRes.subject)) {
logger.info(
`acct subject mismatch (${acctToSubject(
acctLower,
)} !== ${normalizeSubject(
fingerRes.subject,
)}), possible split domain deployment detected, repeating webfinger`,
);
if (!recurse) {
logger.error(
"split domain verification failed (recurse limit reached), aborting",
);
throw new Error(
"split domain verification failed (recurse limit reached), aborting",
);
}
const initialAcct = subjectToAcct(fingerRes.subject);
const initialAcctLower = initialAcct.toLowerCase();
const splitFingerRes = await resolveUserWebFinger(initialAcctLower, false);
const finalAcct = subjectToAcct(splitFingerRes.subject);
const finalAcctLower = finalAcct.toLowerCase();
if (initialAcct !== finalAcct) {
logger.error(
"split domain verification failed (subject mismatch), aborting",
);
throw new Error(
"split domain verification failed (subject mismatch), aborting",
);
}
logger.info(
`split domain configuration detected: ${acctLower} -> ${finalAcctLower}`,
);
return splitFingerRes;
}
return {
subject: fingerRes.subject,
self: self,
};
}
function subjectToAcct(subject: string): string {
if (!subject.startsWith("acct:")) {
logger.error("Subject isnt a valid acct");
throw "Subject isnt a valid acct";
}
return subject.substring(5);
}
function acctToSubject(acct: string): string {
return normalizeSubject(`acct:${acct}`);
}
function normalizeSubject(subject: string): string {
return subject.toLowerCase();
}

View file

@ -9,7 +9,7 @@ import renderKey from "@/remote/activitypub/renderer/key.js";
import { renderPerson } from "@/remote/activitypub/renderer/person.js";
import renderEmoji from "@/remote/activitypub/renderer/emoji.js";
import { inbox as processInbox } from "@/queue/index.js";
import { fetchMeta, isSelfHost } from "backend-rs";
import { fetchMeta, getInternalActor, isSelfHost } from "backend-rs";
import {
Notes,
Users,
@ -24,7 +24,6 @@ import {
checkFetch,
getSignatureUser,
} from "@/remote/activitypub/check-fetch.js";
import { getInstanceActor } from "@/services/instance-actor.js";
import renderFollow from "@/remote/activitypub/renderer/follow.js";
import Featured from "./activitypub/featured.js";
import Following from "./activitypub/following.js";
@ -296,7 +295,7 @@ router.get("/users/:user/collections/featured", Featured);
// publickey
router.get("/users/:user/publickey", async (ctx) => {
const instanceActor = await getInstanceActor();
const instanceActor = await getInternalActor("instance");
if (ctx.params.user === instanceActor.id) {
ctx.body = renderActivity(
renderKey(instanceActor, await getUserKeypair(instanceActor.id)),
@ -360,7 +359,7 @@ async function userInfo(ctx: Router.RouterContext, user: User | null) {
router.get("/users/:user", async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
const instanceActor = await getInstanceActor();
const instanceActor = await getInternalActor("instance");
if (ctx.params.user === instanceActor.id) {
await userInfo(ctx, instanceActor);
return;
@ -387,7 +386,7 @@ router.get("/@:user", async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
if (ctx.params.user === "instance.actor") {
const instanceActor = await getInstanceActor();
const instanceActor = await getInternalActor("instance");
await userInfo(ctx, instanceActor);
return;
}
@ -407,8 +406,8 @@ router.get("/@:user", async (ctx, next) => {
await userInfo(ctx, user);
});
router.get("/actor", async (ctx, next) => {
const instanceActor = await getInstanceActor();
router.get("/actor", async (ctx, _next) => {
const instanceActor = await getInternalActor("instance");
await userInfo(ctx, instanceActor);
});
//#endregion

View file

@ -0,0 +1,59 @@
import { Brackets, type SelectQueryBuilder } from "typeorm";
import type { User } from "@/models/entities/user.js";
import { Followings, Notes } from "@/models/index.js";
import { Cache } from "@/misc/cache.js";
import { apiLogger } from "@/server/api/logger.js";
const cache = new Cache<number>("homeTlQueryData", 60 * 60 * 24);
const cutoff = 250; // 250 posts in the last 7 days, constant determined by comparing benchmarks for cutoff values between 100 and 2500
const logger = apiLogger.createSubLogger("heuristics");
export async function generateFollowingQuery(
q: SelectQueryBuilder<any>,
me: { id: User["id"] },
): Promise<void> {
const followingQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :meId");
const heuristic = await cache.fetch(me.id, async () => {
const curr = new Date();
const prev = new Date();
prev.setDate(prev.getDate() - 7);
return Notes.createQueryBuilder("note")
.where("note.createdAt > :prev", { prev })
.andWhere("note.createdAt < :curr", { curr })
.andWhere(
new Brackets((qb) => {
qb.where(`note.userId IN (${followingQuery.getQuery()})`);
qb.orWhere("note.userId = :meId", { meId: me.id });
}),
)
.getCount()
.then((res) => {
logger.info(
`Calculating heuristics for user ${me.id} took ${
new Date().getTime() - curr.getTime()
}ms`,
);
return res;
});
});
const shouldUseUnion = heuristic < cutoff;
q.andWhere(
new Brackets((qb) => {
if (shouldUseUnion) {
qb.where(
`note.userId = ANY(array(${followingQuery.getQuery()} UNION ALL VALUES (:meId)))`,
);
} else {
qb.where("note.userId = :meId");
qb.orWhere(`note.userId IN (${followingQuery.getQuery()})`);
}
}),
);
q.setParameters({ meId: me.id });
}

View file

@ -0,0 +1,430 @@
import {
Brackets,
type SelectQueryBuilder,
type WhereExpressionBuilder,
} from "typeorm";
import { sqlLikeEscape, sqlRegexEscape } from "backend-rs";
import {
Followings,
NoteFavorites,
NoteReactions,
Users,
} from "@/models/index.js";
import type { Note } from "@/models/entities/note";
import type { Following } from "@/models/entities/following";
import type { NoteFavorite } from "@/models/entities/note-favorite";
const filters = {
from: fromFilter,
"-from": fromFilterInverse,
mention: mentionFilter,
"-mention": mentionFilterInverse,
reply: replyFilter,
"-reply": replyFilterInverse,
to: replyFilter,
"-to": replyFilterInverse,
before: beforeFilter,
until: beforeFilter,
after: afterFilter,
since: afterFilter,
instance: instanceFilter,
"-instance": instanceFilterInverse,
domain: instanceFilter,
"-domain": instanceFilterInverse,
host: instanceFilter,
"-host": instanceFilterInverse,
filter: miscFilter,
"-filter": miscFilterInverse,
in: inFilter,
"-in": inFilterInverse,
has: attachmentFilter,
} as Record<
string,
(query: SelectQueryBuilder<Note>, search: string, id: number) => void
>;
export function generateFtsQuery(
query: SelectQueryBuilder<Note>,
q: string,
): void {
const components = q.trim().split(" ");
const terms: string[] = [];
const finalTerms: string[] = [];
let counter = 0;
let caseSensitive = false;
let matchWords = false;
for (const component of components) {
const split = component.split(":");
if (split.length > 1 && filters[split[0]] !== undefined)
filters[split[0]](query, split.slice(1).join(":"), counter++);
else if (
split.length > 1 &&
(split[0] === "search" || split[0] === "match")
)
matchWords = split[1] === "word" || split[1] === "words";
else if (split.length > 1 && split[0] === "case")
caseSensitive = split[1] === "sensitive";
else terms.push(component);
}
let idx = 0;
let state: "idle" | "quote" | "parenthesis" = "idle";
for (let i = 0; i < terms.length; i++) {
if (state === "idle") {
if (
(terms[i].startsWith('"') && terms[i].endsWith('"')) ||
(terms[i].startsWith("(") && terms[i].endsWith(")"))
) {
finalTerms.push(trimStartAndEnd(terms[i]));
} else if (terms[i].startsWith('"')) {
idx = i;
state = "quote";
} else if (terms[i].startsWith("(")) {
idx = i;
state = "parenthesis";
} else {
finalTerms.push(terms[i]);
}
} else if (state === "quote" && terms[i].endsWith('"')) {
finalTerms.push(extractToken(terms, idx, i));
state = "idle";
} else if (state === "parenthesis" && terms[i].endsWith(")")) {
query.andWhere(
new Brackets((qb) => {
for (const term of extractToken(terms, idx, i).split(" OR ")) {
const id = counter++;
appendSearchQuery(
term,
"or",
query,
qb,
id,
term.startsWith("-"),
matchWords,
caseSensitive,
);
}
}),
);
state = "idle";
}
}
if (state !== "idle") {
finalTerms.push(
...extractToken(terms, idx, terms.length - 1, false)
.substring(1)
.split(" "),
);
}
for (const term of finalTerms) {
const id = counter++;
appendSearchQuery(
term,
"and",
query,
query,
id,
term.startsWith("-"),
matchWords,
caseSensitive,
);
}
}
function fromFilter(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.userId = (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function fromFilterInverse(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.userId <> (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function mentionFilter(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, {
materialized: true,
});
query.andWhere(
`note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[]`,
);
query.setParameters(userQuery.getParameters());
}
function mentionFilterInverse(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, {
materialized: true,
});
query.andWhere(
`NOT (note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[])`,
);
query.setParameters(userQuery.getParameters());
}
function replyFilter(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.replyUserId = (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function replyFilterInverse(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
const userQuery = generateUserSubquery(filter, id);
query.andWhere(`note.replyUserId <> (${userQuery.getQuery()})`);
query.setParameters(userQuery.getParameters());
}
function beforeFilter(query: SelectQueryBuilder<Note>, filter: string) {
query.andWhere("note.createdAt < :before", { before: filter });
}
function afterFilter(query: SelectQueryBuilder<Note>, filter: string) {
query.andWhere("note.createdAt > :after", { after: filter });
}
function instanceFilter(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
if (filter === "local") {
query.andWhere("note.userHost IS NULL");
} else {
query.andWhere(`note.userHost = :instance_${id}`);
query.setParameter(`instance_${id}`, filter);
}
}
function instanceFilterInverse(
query: SelectQueryBuilder<Note>,
filter: string,
id: number,
) {
if (filter === "local") {
query.andWhere("note.userHost IS NOT NULL");
} else {
query.andWhere(`note.userHost <> :instance_${id}`);
query.setParameter(`instance_${id}`, filter);
}
}
function miscFilter(query: SelectQueryBuilder<Note>, filter: string) {
let subQuery: SelectQueryBuilder<Following> | null = null;
if (filter === "followers") {
subQuery = Followings.createQueryBuilder("following")
.select("following.followerId")
.where("following.followeeId = :meId");
} else if (filter === "following") {
subQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :meId");
} else if (filter === "replies" || filter === "reply") {
query.andWhere("note.replyId IS NOT NULL");
} else if (
filter === "boosts" ||
filter === "boost" ||
filter === "renotes" ||
filter === "renote"
) {
query.andWhere("note.renoteId IS NOT NULL");
}
if (subQuery !== null)
query.andWhere(`note.userId IN (${subQuery.getQuery()})`);
}
function miscFilterInverse(query: SelectQueryBuilder<Note>, filter: string) {
let subQuery: SelectQueryBuilder<Following> | null = null;
if (filter === "followers") {
subQuery = Followings.createQueryBuilder("following")
.select("following.followerId")
.where("following.followeeId = :meId");
} else if (filter === "following") {
subQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :meId");
} else if (filter === "replies" || filter === "reply") {
query.andWhere("note.replyId IS NULL");
} else if (
filter === "boosts" ||
filter === "boost" ||
filter === "renotes" ||
filter === "renote"
) {
query.andWhere("note.renoteId IS NULL");
}
if (subQuery !== null)
query.andWhere(`note.userId NOT IN (${subQuery.getQuery()})`);
}
function inFilter(query: SelectQueryBuilder<Note>, filter: string) {
let subQuery: SelectQueryBuilder<NoteFavorite> | null = null;
if (filter === "bookmarks") {
subQuery = NoteFavorites.createQueryBuilder("bookmark")
.select("bookmark.noteId")
.where("bookmark.userId = :meId");
} else if (
filter === "favorites" ||
filter === "favourites" ||
filter === "reactions" ||
filter === "likes"
) {
subQuery = NoteReactions.createQueryBuilder("react")
.select("react.noteId")
.where("react.userId = :meId");
}
if (subQuery !== null) query.andWhere(`note.id IN (${subQuery.getQuery()})`);
}
function inFilterInverse(query: SelectQueryBuilder<Note>, filter: string) {
let subQuery: SelectQueryBuilder<NoteFavorite> | null = null;
if (filter === "bookmarks") {
subQuery = NoteFavorites.createQueryBuilder("bookmark")
.select("bookmark.noteId")
.where("bookmark.userId = :meId");
} else if (
filter === "favorites" ||
filter === "favourites" ||
filter === "reactions" ||
filter === "likes"
) {
subQuery = NoteReactions.createQueryBuilder("react")
.select("react.noteId")
.where("react.userId = :meId");
}
if (subQuery !== null)
query.andWhere(`note.id NOT IN (${subQuery.getQuery()})`);
}
function attachmentFilter(query: SelectQueryBuilder<Note>, filter: string) {
switch (filter) {
case "image":
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%image/%'`);
break;
case "video":
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%video/%'`);
break;
case "audio":
query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%audio/%'`);
break;
case "file":
query.andWhere(`note."attachedFileTypes" <> '{}'`);
query.andWhere(
`NOT (note."attachedFileTypes"::varchar ILIKE '%image/%')`,
);
query.andWhere(
`NOT (note."attachedFileTypes"::varchar ILIKE '%video/%')`,
);
query.andWhere(
`NOT (note."attachedFileTypes"::varchar ILIKE '%audio/%')`,
);
break;
default:
break;
}
}
function generateUserSubquery(filter: string, id: number) {
if (filter.startsWith("@")) filter = filter.substring(1);
const split = filter.split("@");
const query = Users.createQueryBuilder("user")
.select("user.id")
.where(`user.usernameLower = :user_${id}`)
.andWhere(
`user.host ${split[1] !== undefined ? `= :host_${id}` : "IS NULL"}`,
);
query.setParameter(`user_${id}`, split[0].toLowerCase());
if (split[1] !== undefined)
query.setParameter(`host_${id}`, split[1].toLowerCase());
return query;
}
function extractToken(
array: string[],
start: number,
end: number,
trim = true,
) {
const slice = array.slice(start, end + 1).join(" ");
return trim ? trimStartAndEnd(slice) : slice;
}
function trimStartAndEnd(str: string) {
return str.substring(1, str.length - 1);
}
function appendSearchQuery(
term: string,
mode: "and" | "or",
query: SelectQueryBuilder<Note>,
qb: SelectQueryBuilder<Note> | WhereExpressionBuilder,
id: number,
negate: boolean,
matchWords: boolean,
caseSensitive: boolean,
) {
const sql = `note.text ${getSearchMatchOperator(
negate,
matchWords,
caseSensitive,
)} :q_${id}`;
if (mode === "and") qb.andWhere(sql);
else if (mode === "or") qb.orWhere(sql);
query.setParameter(
`q_${id}`,
escapeSqlSearchParam(term.substring(negate ? 1 : 0), matchWords),
);
}
function getSearchMatchOperator(
negate: boolean,
matchWords: boolean,
caseSensitive: boolean,
) {
return `${negate ? "NOT " : ""}${
matchWords ? (caseSensitive ? "~" : "~*") : caseSensitive ? "LIKE" : "ILIKE"
}`;
}
function escapeSqlSearchParam(param: string, matchWords: boolean) {
return matchWords
? `\\y${sqlRegexEscape(param)}\\y`
: `%${sqlLikeEscape(param)}%`;
}

View file

@ -0,0 +1,25 @@
import { Brackets, type SelectQueryBuilder } from "typeorm";
import type { User } from "@/models/entities/user.js";
import { UserListJoinings, UserLists } from "@/models/index.js";
export function generateListQuery(
q: SelectQueryBuilder<any>,
me: { id: User["id"] },
): void {
const listQuery = UserLists.createQueryBuilder("list")
.select("list.id")
.andWhere("list.userId = :meId");
const memberQuery = UserListJoinings.createQueryBuilder("member")
.select("member.userId")
.where(`member.userListId IN (${listQuery.getQuery()})`);
q.andWhere(
new Brackets((qb) => {
qb.where("note.userId = :meId");
qb.orWhere(`note.userId NOT IN (${memberQuery.getQuery()})`);
}),
);
q.setParameters({ meId: me.id });
}

View file

@ -3,7 +3,13 @@ import { User } from "@/models/entities/user.js";
import { Users, UsedUsernames } from "@/models/index.js";
import { UserProfile } from "@/models/entities/user-profile.js";
import { IsNull } from "typeorm";
import { genIdAt, generateUserToken, hashPassword, toPuny } from "backend-rs";
import {
countLocalUsers,
genIdAt,
generateUserToken,
hashPassword,
toPuny,
} from "backend-rs";
import { UserKeypair } from "@/models/entities/user-keypair.js";
import { UsedUsername } from "@/models/entities/used-username.js";
import { db } from "@/db/postgre.js";
@ -18,9 +24,7 @@ export async function signup(opts: {
const { username, password, passwordHash, host } = opts;
let hash = passwordHash;
const userCount = await Users.countBy({
host: IsNull(),
});
const userCount = await countLocalUsers();
if (config.maxUserSignups != null && userCount > config.maxUserSignups) {
throw new Error("MAX_USERS_REACHED");
@ -103,11 +107,7 @@ export async function signup(opts: {
usernameLower: username.toLowerCase(),
host: host == null ? null : toPuny(host),
token: secret,
isAdmin:
(await Users.countBy({
host: IsNull(),
isAdmin: true,
})) === 0,
isAdmin: (await countLocalUsers()) === 0,
}),
);

View file

@ -1,7 +1,7 @@
import define from "@/server/api/define.js";
import { Users } from "@/models/index.js";
import { signup } from "@/server/api/common/signup.js";
import { IsNull } from "typeorm";
import { countLocalUsers } from "backend-rs";
export const meta = {
tags: ["admin"],
@ -32,11 +32,8 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, _me, token) => {
const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null;
const noUsers =
(await Users.countBy({
host: IsNull(),
})) === 0;
if (!noUsers && !me?.isAdmin) throw new Error("access denied");
if (!me?.isAdmin && (await countLocalUsers()) !== 0)
throw new Error("access denied");
if (token) throw new Error("access denied");
const { account, secret } = await signup({

View file

@ -1,6 +1,6 @@
import define from "@/server/api/define.js";
import { AbuseUserReports, Users } from "@/models/index.js";
import { getInstanceActor } from "@/services/instance-actor.js";
import { getInternalActor } from "backend-rs";
import { deliver } from "@/queue/index.js";
import { renderActivity } from "@/remote/activitypub/renderer/index.js";
import { renderFlag } from "@/remote/activitypub/renderer/flag.js";
@ -29,7 +29,7 @@ export default define(meta, paramDef, async (ps, me) => {
}
if (ps.forward && report.targetUserHost != null) {
const actor = await getInstanceActor();
const actor = await getInternalActor("instance");
const targetUser = await Users.findOneByOrFail({ id: report.targetUserId });
deliver(

View file

@ -1,7 +1,7 @@
import JSON5 from "json5";
import { IsNull, MoreThan } from "typeorm";
import { config } from "@/config.js";
import { fetchMeta } from "backend-rs";
import { countLocalUsers, fetchMeta } from "backend-rs";
import { Ads, Emojis, Users } from "@/models/index.js";
import define from "@/server/api/define.js";
@ -501,11 +501,7 @@ export default define(meta, paramDef, async (ps, me) => {
instance.privateMode && !me ? [] : instance.pinnedClipId,
cacheRemoteFiles: instance.cacheRemoteFiles,
markLocalFilesNsfwByDefault: instance.markLocalFilesNsfwByDefault,
requireSetup:
(await Users.countBy({
host: IsNull(),
isAdmin: true,
})) === 0,
requireSetup: (await countLocalUsers()) === 0,
}
: {}),
};

View file

@ -1,12 +1,8 @@
import { Notes } from "@/models/index.js";
import type { Note } from "@/models/entities/note.js";
import define from "@/server/api/define.js";
import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js";
import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js";
import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js";
import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js";
import { sqlLikeEscape } from "backend-rs";
import type { SelectQueryBuilder } from "typeorm";
import { searchNotes, type SearchParams } from "@/misc/search.js";
export const meta = {
tags: ["notes"],
@ -69,76 +65,23 @@ export const paramDef = {
} as const;
export default define(meta, paramDef, async (ps, me) => {
async function search(
modifier?: (query: SelectQueryBuilder<Note>) => void,
): Promise<Note[]> {
const query = makePaginationQuery(
Notes.createQueryBuilder("note"),
ps.sinceId,
ps.untilId,
ps.sinceDate ?? undefined,
ps.untilDate ?? undefined,
);
modifier?.(query);
if (ps.userId != null) {
query.andWhere("note.userId = :userId", { userId: ps.userId });
}
if (ps.channelId != null) {
query.andWhere("note.channelId = :channelId", {
channelId: ps.channelId,
});
}
query.innerJoinAndSelect("note.user", "user");
// "from: me": search all (public, home, followers, specified) my posts
// otherwise: search public indexable posts only
if (ps.userId == null || ps.userId !== me?.id) {
query
.andWhere("note.visibility = 'public'")
.andWhere("user.isIndexable = TRUE");
}
if (ps.userId != null) {
query.andWhere("note.userId = :userId", { userId: ps.userId });
}
if (ps.host === null) {
query.andWhere("note.userHost IS NULL");
}
if (ps.host != null) {
query.andWhere("note.userHost = :userHost", { userHost: ps.host });
}
if (ps.withFiles === true) {
query.andWhere("note.fileIds != '{}'");
}
query
.leftJoinAndSelect("user.avatar", "avatar")
.leftJoinAndSelect("user.banner", "banner")
.leftJoinAndSelect("note.reply", "reply")
.leftJoinAndSelect("note.renote", "renote")
.leftJoinAndSelect("reply.user", "replyUser")
.leftJoinAndSelect("replyUser.avatar", "replyUserAvatar")
.leftJoinAndSelect("replyUser.banner", "replyUserBanner")
.leftJoinAndSelect("renote.user", "renoteUser")
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
generateVisibilityQuery(query, me);
if (me) generateMutedUserQuery(query, me);
if (me) generateBlockedUserQuery(query, me);
return await query.take(ps.limit).getMany();
}
let notes: Note[];
const params: SearchParams = {
withFilesOnly: ps.withFiles ?? false,
limit: ps.limit,
myId: me?.id,
sinceId: ps.sinceId,
untilId: ps.untilId,
sinceDate: ps.sinceDate ?? undefined,
untilDate: ps.untilDate ?? undefined,
userId: ps.userId,
channelId: ps.channelId,
host: ps.host,
};
if (ps.query != null) {
const q = sqlLikeEscape(ps.query);
const searchWord = sqlLikeEscape(ps.query);
if (ps.searchCwAndAlt) {
// Whether we should return latest notes first
@ -159,15 +102,15 @@ export default define(meta, paramDef, async (ps, me) => {
...new Map(
(
await Promise.all([
search((query) => {
query.andWhere("note.text &@~ :q", { q });
searchNotes(params, (query) => {
query.andWhere("note.text &@~ :q", { q: searchWord });
}),
search((query) => {
query.andWhere("note.cw &@~ :q", { q });
searchNotes(params, (query) => {
query.andWhere("note.cw &@~ :q", { q: searchWord });
}),
search((query) => {
searchNotes(params, (query) => {
query
.andWhere("drive_file.comment &@~ :q", { q })
.andWhere("drive_file.comment &@~ :q", { q: searchWord })
.innerJoin("note.files", "drive_file");
}),
])
@ -179,12 +122,12 @@ export default define(meta, paramDef, async (ps, me) => {
.sort(compare)
.slice(0, ps.limit);
} else {
notes = await search((query) => {
query.andWhere("note.text &@~ :q", { q });
notes = await searchNotes(params, (query) => {
query.andWhere("note.text &@~ :q", { q: searchWord });
});
}
} else {
notes = await search();
notes = await searchNotes(params);
}
return await Notes.packMany(notes, me);

View file

@ -1,6 +1,7 @@
import { Instances, Users, Notes } from "@/models/index.js";
import define from "@/server/api/define.js";
import { IsNull } from "typeorm";
import { countLocalUsers } from "backend-rs";
export const meta = {
requireCredential: false,
@ -69,11 +70,7 @@ export default define(meta, paramDef, async () => {
// usersCount
Users.count(),
// originalUsersCount
Users.count({
where: {
host: IsNull(),
},
}),
countLocalUsers(),
// instances
Instances.count(),
]);

View file

@ -7,10 +7,7 @@ import Router from "@koa/router";
import multer from "@koa/multer";
import bodyParser from "koa-bodyparser";
import cors from "@koa/cors";
import {
apiMastodonCompatible,
getClient,
} from "./mastodon/ApiMastodonCompatibleService.js";
import { setupMastodonApi } from "./mastodon/index.js";
import { AccessTokens, Users } from "@/models/index.js";
import { config } from "@/config.js";
import endpoints from "./endpoints.js";
@ -20,10 +17,6 @@ import signup from "./private/signup.js";
import signin from "./private/signin.js";
import signupPending from "./private/signup-pending.js";
import verifyEmail from "./private/verify-email.js";
import { koaBody } from "koa-body";
import { convertAttachment } from "./mastodon/converters.js";
import { apiLogger } from "./logger.js";
import { inspect } from "node:util";
// Init app
const app = new Koa();
@ -66,64 +59,7 @@ router.use(
}),
);
mastoRouter.use(
koaBody({
multipart: true,
urlencoded: true,
}),
);
mastoFileRouter.post("/v1/media", upload.single("file"), async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const multipartData = await ctx.file;
if (!multipartData) {
ctx.body = { error: "No image" };
ctx.status = 401;
return;
}
const data = await client.uploadMedia(multipartData);
ctx.body = convertAttachment(data.data as Entity.Attachment);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
mastoFileRouter.post("/v2/media", upload.single("file"), async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const multipartData = await ctx.file;
if (!multipartData) {
ctx.body = { error: "No image" };
ctx.status = 401;
return;
}
const data = await client.uploadMedia(multipartData, ctx.request.body);
ctx.body = convertAttachment(data.data as Entity.Attachment);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
mastoRouter.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = { ...ctx.request.body, ...ctx.request.query };
}
}
await next();
});
apiMastodonCompatible(mastoRouter);
setupMastodonApi(mastoRouter);
/**
* Register endpoint handlers

View file

@ -1,163 +0,0 @@
import Router from "@koa/router";
import megalodon, { MegalodonInterface } from "megalodon";
import { apiAuthMastodon } from "./endpoints/auth.js";
import { apiAccountMastodon } from "./endpoints/account.js";
import { apiStatusMastodon } from "./endpoints/status.js";
import { apiFilterMastodon } from "./endpoints/filter.js";
import { apiTimelineMastodon } from "./endpoints/timeline.js";
import { apiNotificationsMastodon } from "./endpoints/notifications.js";
import { apiSearchMastodon } from "./endpoints/search.js";
import { getInstance } from "./endpoints/meta.js";
import {
convertAccount,
convertAnnouncement,
convertFilter,
} from "./converters.js";
import { fromMastodonId } from "backend-rs";
import { Users } from "@/models/index.js";
import { IsNull } from "typeorm";
import { apiLogger } from "../logger.js";
import { inspect } from "node:util";
export function getClient(
BASE_URL: string,
authorization: string | undefined,
): MegalodonInterface {
const accessTokenArr = authorization?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
const generator = (megalodon as any).default;
const client = generator(BASE_URL, accessToken) as MegalodonInterface;
return client;
}
export function apiMastodonCompatible(router: Router): void {
apiAuthMastodon(router);
apiAccountMastodon(router);
apiStatusMastodon(router);
apiFilterMastodon(router);
apiTimelineMastodon(router);
apiNotificationsMastodon(router);
apiSearchMastodon(router);
router.get("/v1/custom_emojis", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceCustomEmojis();
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/instance", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.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 Users.findOne({
where: {
host: IsNull(),
isAdmin: true,
isDeleted: false,
isSuspended: false,
},
order: { id: "ASC" },
});
const contact =
admin == null
? null
: convertAccount((await client.getAccount(admin.id)).data);
ctx.body = await getInstance(data.data, contact);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/announcements", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceAnnouncements();
ctx.body = data.data.map((announcement) =>
convertAnnouncement(announcement),
);
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>(
"/v1/announcements/:id/dismiss",
async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.dismissInstanceAnnouncement(
fromMastodonId(ctx.params.id),
);
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.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();
ctx.body = data.data.map((filter) => convertFilter(filter));
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/trends", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.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();
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/preferences", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.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();
ctx.body = data.data;
} catch (e: any) {
apiLogger.error(inspect(e));
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -1,95 +0,0 @@
import type { Entity } from "megalodon";
import { toMastodonId } from "backend-rs";
function simpleConvert(data: any) {
// copy the object to bypass weird pass by reference bugs
const result = Object.assign({}, data);
result.id = toMastodonId(data.id);
return result;
}
export function convertAccount(account: Entity.Account) {
return simpleConvert(account);
}
export function convertAnnouncement(announcement: Entity.Announcement) {
return simpleConvert(announcement);
}
export function convertAttachment(attachment: Entity.Attachment) {
const converted = simpleConvert(attachment);
// ref: https://github.com/whitescent/Mastify/pull/102
if (converted.meta == null) return converted;
const result = {
...converted,
meta: {
...converted.meta,
original: {
...converted.meta,
},
},
};
return result;
}
export function convertFilter(filter: Entity.Filter) {
return simpleConvert(filter);
}
export function convertList(list: Entity.List) {
return simpleConvert(list);
}
export function convertFeaturedTag(tag: Entity.FeaturedTag) {
return simpleConvert(tag);
}
export function convertNotification(notification: Entity.Notification) {
notification.account = convertAccount(notification.account);
notification.id = toMastodonId(notification.id);
if (notification.status)
notification.status = convertStatus(notification.status);
if (notification.reaction)
notification.reaction = convertReaction(notification.reaction);
return notification;
}
export function convertPoll(poll: Entity.Poll) {
return simpleConvert(poll);
}
export function convertReaction(reaction: Entity.Reaction) {
if (reaction.accounts) {
reaction.accounts = reaction.accounts.map(convertAccount);
}
return reaction;
}
export function convertRelationship(relationship: Entity.Relationship) {
return simpleConvert(relationship);
}
export function convertStatus(status: Entity.Status) {
status.account = convertAccount(status.account);
status.id = toMastodonId(status.id);
if (status.in_reply_to_account_id)
status.in_reply_to_account_id = toMastodonId(status.in_reply_to_account_id);
if (status.in_reply_to_id)
status.in_reply_to_id = toMastodonId(status.in_reply_to_id);
status.media_attachments = status.media_attachments.map((attachment) =>
convertAttachment(attachment),
);
status.mentions = status.mentions.map((mention) => ({
...mention,
id: toMastodonId(mention.id),
}));
if (status.poll) status.poll = convertPoll(status.poll);
if (status.reblog) status.reblog = convertStatus(status.reblog);
if (status.quote) status.quote = convertStatus(status.quote);
status.reactions = status.reactions.map(convertReaction);
return status;
}
export function convertConversation(conversation: Entity.Conversation) {
conversation.id = toMastodonId(conversation.id);
conversation.accounts = conversation.accounts.map(convertAccount);
if (conversation.last_status) {
conversation.last_status = convertStatus(conversation.last_status);
}
return conversation;
}

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