Compare commits

...

88 Commits

Author SHA1 Message Date
naskya 4104cbf5b5
Merge branch 'develop' into feat/schedule-create 2024-05-07 21:27:40 +09:00
naskya 3e4b093cd8
meta: update gitignore 2024-05-07 21:23:17 +09:00
naskya 02380eff4d
refactor (backend): remove redundancy 2024-05-07 21:20:03 +09:00
naskya 5daeaf1de2
chore (backend): wrap relations in TypeORM Relation<...> 2024-05-07 21:05:31 +09:00
naskya 3ceaf99df6
chore (backend): tweak migration queries 2024-05-07 21:04:11 +09:00
naskya 4277ad0b59
meta: update COPYING & include LICENSE in pre-built images 2024-05-07 20:54:47 +09:00
naskya fc65d8c1c3
docs: update api-change.md 2024-05-07 20:52:11 +09:00
naskya 3b3d457c3e
ci: restrict paths 2024-05-07 18:34:18 +09:00
naskya 1128e243d3
container: fix dockerignore 2024-05-07 18:01:05 +09:00
naskya 39e08f57e8
ci: remove unneeded argument 2024-05-07 18:01:05 +09:00
naskya 09ef642905
ci: skip builds if unneeded 2024-05-07 17:36:23 +09:00
naskya 1b8748bc8c
another attempt to build an image inside container inside container 2024-05-07 17:30:57 +09:00
naskya 82c98ae72f
ci: modify buildah args 2024-05-07 07:26:33 +09:00
naskya 5b3f93457b
dev: add renovate 2024-05-07 06:58:00 +09:00
naskya 4d9c0f8e7b
ci: fix syntax 2024-05-07 06:11:31 +09:00
naskya bf2b624bc9
ci: build OCI container image on develop 2024-05-07 05:52:43 +09:00
naskya 5261eb24b6
ci: restrict project path 2024-05-07 05:26:05 +09:00
naskya d440e9b388
ci: revise tasks 2024-05-07 04:58:59 +09:00
naskya 14b285f882 Merge branch 'refactor/is-safe-url' into 'develop'
refactor (backend): port isValidUrl to backend-rs


See merge request firefish/firefish!10795
2024-05-06 17:11:51 +00:00
naskya baa5c402db
ci: apt-get update first & fix paths 2024-05-07 01:54:29 +09:00
naskya 5b01d3574f
refactor (backend): port isValidUrl to backend-rs 2024-05-07 00:56:37 +09:00
naskya e3a98ebc72 Merge branch 'userLang' into 'develop'
Add server-side per-user UI language

Co-authored-by: eana <coder@apps.1a23.com>

See merge request firefish/firefish!10793
2024-05-06 15:31:18 +00:00
naskya 7fe7f90350
ci: revise build config 2024-05-07 00:22:51 +09:00
naskya 8ed942e00f
chore: update auto-generated files 2024-05-06 23:13:31 +09:00
naskya ddfdd038ad
chore: update downgrade.sql 2024-05-06 23:10:39 +09:00
naskya 7fdd44cf8d
locale: update translations 2024-05-06 23:07:57 +09:00
naskya 0c4826becf
dev: copy backend-rs/index.{js,d.ts} to built/index.{js,d.ts} if not exist
https://firefish.dev/firefish/firefish/-/merge_requests/10780#note_5685
2024-05-06 22:54:10 +09:00
naskya ecd8e3d109
ci: remove git clean flags 2024-05-06 22:51:42 +09:00
naskya a3b156441a
ci: temporary fix for cargo test failure due to missing meta.json 2024-05-06 19:38:35 +09:00
naskya ecbd8a8724
ci: save node_modules and target 2024-05-06 19:23:43 +09:00
naskya 442dc33a34
ci: exec build & cargo test only for now 2024-05-06 19:08:28 +09:00
naskya c8372767fa
ci: attempt to fix permission 2024-05-06 19:00:34 +09:00
naskya 8e497b41cf
messed up 2024-05-06 18:44:28 +09:00
naskya bfdf73caeb
ci: fix permisson 2024-05-06 18:38:54 +09:00
naskya 5b18f9761c
ci: fix .git 2024-05-06 18:29:31 +09:00
naskya 641ff742bb
ci: add dependencies of sea-orm-cli 2024-05-06 18:26:50 +09:00
naskya e6121946aa
ci: another fix 2024-05-06 18:11:30 +09:00
naskya c6212ff8f4
ci: use CI_JOB_TOKEN 2024-05-06 18:06:56 +09:00
naskya d582a84c57
ci: install postgresql client 2024-05-06 17:58:26 +09:00
naskya a7978e2b08
ci: non-interactive shell option 2024-05-06 17:46:45 +09:00
naskya 766bac3dee
ci: give alias for services 2024-05-06 17:14:47 +09:00
naskya 7360736966
ci: fix typo 2024-05-06 17:07:42 +09:00
naskya e797849e9b
ci: attempt to add a CI task for merge requests 2024-05-06 17:00:36 +09:00
eana ef57735e6a fix typo 2024-05-06 05:26:38 +00:00
eana e7c33835b2 Add server-side per-user UI language 2024-05-06 05:14:44 +00:00
naskya 4e83dbd01f Merge branch 'refactor/remove-gulp' into 'develop'
refactor: replace gulp with a simple script


See merge request firefish/firefish!10791
2024-05-06 04:45:17 +00:00
naskya dd74eabae1
refactor (backend): port nodeinfo fetcher to backend-rs 2024-05-06 08:12:21 +09:00
naskya 711618b42c
test (backend-rs): add tests for nodeinfo (de)serialization 2024-05-06 05:20:13 +09:00
naskya 510207b101
refactor (backend-rs): separate nodeinfo generator and schema 2024-05-06 04:23:38 +09:00
naskya 49825853c1
refactor (backend): port nodeinfo generator to backend-rs 2024-05-06 03:01:55 +09:00
naskya 359fef0a42
chore: replace old comments 2024-05-05 21:22:57 +09:00
naskya fda81a9f91
chore: use absolute path for file operations 2024-05-05 21:04:20 +09:00
naskya c505c6df36
fix: remove old locale files 2024-05-05 20:59:26 +09:00
naskya 9a4a75bf92 Merge branch 'fix/click' into 'develop'
fix: Click event of MenuParent unexpectedly goes to underlying element

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

See merge request firefish/firefish!10792
2024-05-05 11:42:25 +00:00
Linca 5e5d01d407 fix: Click event of MenuParent unexpectedly goes to underlying element
Co-authored-by: Lhcfl <Lhcfl@outlook.com>
2024-05-05 11:42:25 +00:00
naskya d114b8ec1d
chore: format 2024-05-05 14:58:56 +09:00
naskya d2471b6db7
refactor (backend-rs): replace reqwest with isahc
reqwest is feature-rich, but we will need isahc http client for push notifications (!10760)
isahc http client is also good btw :)
2024-05-05 14:53:45 +09:00
naskya 341b43ed71
refactor: replace gulp with a simple script 2024-05-05 02:19:58 +09:00
naskya 6d64358674
fix (client): missing MFM function props not falling back correctly 2024-05-05 01:15:04 +09:00
naskya 4992999bb7
test (backend-rs): add tests 2024-05-04 22:59:49 +09:00
naskya 38c0de39b9
chore (backend-rs): add docs for functions in database/cache 2024-05-04 22:50:46 +09:00
naskya 722d090f8d
chore (backend-rs): remove unneeded 'static 2024-05-04 22:49:11 +09:00
naskya b185c0c87e
feat (backend-rs): add cache::delete_all 2024-05-04 21:24:20 +09:00
naskya 8c22b0d07f
test (backend-rs): fix version format 2024-05-04 16:17:33 +09:00
naskya 0f4c05a64f
ci: add 'ci' feature flag to backend-rs 2024-05-04 16:14:23 +09:00
naskya bc39badf51
chore (client): remove unused code 2024-05-04 16:08:41 +09:00
naskya 1ee8198c6f
v20240504 2024-05-04 16:04:31 +09:00
naskya 15c32d5510
docs: explicitly list Perl as a build dependency 2024-05-04 15:30:05 +09:00
naskya bf3f4906ac
docs: update dev docs 2024-05-04 14:59:11 +09:00
naskya dbe10f88b0
docs: update changelog 2024-05-04 14:54:42 +09:00
naskya 369b1d72df
fix/perf (backend): port latest version check to backend-rs, address excessive requests to firefish.dev 2024-05-04 14:44:20 +09:00
naskya e6ba0a002f
refactor (backend-rs): add cache::{get_one, set_one, delete_one} 2024-05-04 13:22:20 +09:00
naskya 37e03007f0
refactor (backend-rs): misc/redis_cache -> database/cache 2024-05-04 13:22:20 +09:00
naskya f66ecd0759 Merge branch 'fix/follow_me_with_host' into 'develop'
fix: follow-me generate wrong link for other server

Co-authored-by: 老周部落 <laozhoubuluo@gmail.com>

See merge request firefish/firefish!10785
2024-05-03 14:41:37 +00:00
naskya 5ab81c2799
docs: Docker -> Podman 2024-05-03 23:14:41 +09:00
老周部落 8a2f3b3e36
fix: follow-me generate wrong link for other server 2024-05-03 22:13:54 +08:00
naskya f7c576f8fb Merge branch 'fix/10915' into 'develop'
feat: add angle constraint for MkPullToRefresh

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

Closes #10915

See merge request firefish/firefish!10784
2024-05-03 02:20:29 +00:00
Lhcfl 4536ac0678 reviewed 2024-05-03 00:28:46 +08:00
naskya 4a0e4a4c91 Merge branch 'fix/notifications-swiper' into 'develop'
fix: notifications swiper not reload

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

See merge request firefish/firefish!10782
2024-05-02 13:55:41 +00:00
naskya a8f659ab88
chore: format 2024-05-02 21:46:14 +09:00
naskya 64c07a2406
fix (backend): tell TypeORM that some columns are no longer indexed
should have done in caae8474a6
2024-05-02 21:45:58 +09:00
naskya 6e9ae06990 Merge branch 'fix/10918' into 'develop'
fix: escape ambiguous Mfm marks from html

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

Closes #10918

See merge request firefish/firefish!10786
2024-05-02 11:28:08 +00:00
naskya caae8474a6
chore (backend): drop unused database indexes
Based on the PostgreSQL analitics on the following servers' database:

- dvd.chat
- iwshkey.com
- minazukey.uk
- post.naskya.net
- post.sup39.dev
- stelpolva.moe

Thank you all for your helps!
2024-05-02 19:31:53 +09:00
naskya d82f212c24
dev: update Makefile 2024-05-01 17:48:37 +09:00
Lhcfl a4a96f0026 fix: escape ambiguous Mfm marks from html 2024-05-01 11:47:14 +08:00
Lhcfl 5ad9bd8ceb feat: add angle constraint for MkPullToRefresh 2024-04-30 19:51:40 +08:00
Lhcfl f40c201670 remove forgotten debug parameter 2024-04-30 19:06:55 +08:00
Lhcfl c8c7abe6ef fix: notifications swiper not reload 2024-04-30 19:04:39 +08:00
86 changed files with 1985 additions and 3559 deletions

View File

@ -1,195 +1,11 @@
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Firefish configuration
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ┌─────┐
#───┘ URL └─────────────────────────────────────────────────────
# Final accessible URL seen by a user.
url: https://example.tld/
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT!
# ┌───────────────────────┐
#───┘ Port and TLS settings └───────────────────────────────────
#
# Misskey requires a reverse proxy to support HTTPS connections.
#
# +----- https://example.tld/ ------------+
# +------+ |+-------------+ +----------------+|
# | User | ---> || Proxy (443) | ---> | Misskey (3000) ||
# +------+ |+-------------+ +----------------+|
# +---------------------------------------+
#
# You need to set up a reverse proxy. (e.g. nginx)
# An encrypted connection with HTTPS is highly recommended
# because tokens may be transferred in GET requests.
# The port that your Misskey server should listen on.
url: http://localhost:3000
port: 3000
# ┌──────────────────────────┐
#───┘ PostgreSQL configuration └────────────────────────────────
db:
host: postgres
port: 5432
# Database name
db: postgres
# Auth
user: postgres
pass: test
# Whether disable Caching queries
#disableCache: true
# Extra Connection options
#extra:
# ssl: true
# ┌─────────────────────┐
#───┘ Redis configuration └─────────────────────────────────────
db: firefish_db
user: firefish
pass: password
redis:
host: redis
port: 6379
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
#pass: example-pass
#prefix: example-prefix
#db: 1
# ┌─────────────────────────────┐
#───┘ Elasticsearch configuration └─────────────────────────────
#elasticsearch:
# host: localhost
# port: 9200
# ssl: false
# user:
# pass:
# ┌───────────────┐
#───┘ ID generation └───────────────────────────────────────────
# You can select the ID generation method.
# You don't usually need to change this setting, but you can
# change it according to your preferences.
# Available methods:
# aid ... Short, Millisecond accuracy
# meid ... Similar to ObjectID, Millisecond accuracy
# ulid ... Millisecond accuracy
# objectid ... This is left for backward compatibility
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# ID SETTINGS AFTER THAT!
id: 'aid'
# ┌─────────────────────┐
#───┘ Other configuration └─────────────────────────────────────
# Max note length, should be < 8000.
#maxNoteLength: 3000
# Whether disable HSTS
#disableHsts: true
# Number of worker processes
#clusterLimit: 1
# Job concurrency per worker
# deliverJobConcurrency: 128
# inboxJobConcurrency: 16
# Job rate limiter
# deliverJobPerSec: 128
# inboxJobPerSec: 16
# Job attempts
# deliverJobMaxAttempts: 12
# inboxJobMaxAttempts: 8
# IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4
# Syslog option
#syslog:
# host: localhost
# port: 514
# Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128
#proxyBypassHosts: [
# 'example.com',
# '192.0.2.8'
#]
# Proxy for SMTP/SMTPS
#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5
# Media Proxy
#mediaProxy: https://example.com/proxy
# Proxy remote files (default: false)
#proxyRemoteFiles: true
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
# Managed hosting settings
# !!!!!!!!!!
# >>>>>> NORMAL SELF-HOSTERS, STAY AWAY! <<<<<<
# >>>>>> YOU DON'T NEED THIS! <<<<<<
# !!!!!!!!!!
# Each category is optional, but if each item in each category is mandatory!
# If you mess this up, that's on you, you've been warned...
#maxUserSignups: 100
#isManagedHosting: true
#deepl:
# managed: true
# authKey: ''
# isPro: false
#
#email:
# managed: true
# address: 'example@email.com'
# host: 'email.com'
# port: 587
# user: 'example@email.com'
# pass: ''
# useImplicitSslTls: false
#
#objectStorage:
# managed: true
# baseUrl: ''
# bucket: ''
# prefix: ''
# endpoint: ''
# region: ''
# accessKey: ''
# secretKey: ''
# useSsl: true
# connnectOverProxy: false
# setPublicReadOnUpload: true
# s3ForcePathStyle: true
# !!!!!!!!!!
# >>>>>> AGAIN, NORMAL SELF-HOSTERS, STAY AWAY! <<<<<<
# >>>>>> YOU DON'T NEED THIS, ABOVE SETTINGS ARE FOR MANAGED HOSTING ONLY! <<<<<<
# !!!!!!!!!!
# Seriously. Do NOT fill out the above settings if you're self-hosting.
# They're much better off being set from the control panel.

View File

@ -51,12 +51,11 @@ title.svg
/dev
/docs
/scripts
!/scripts/copy-assets.mjs
biome.json
COPYING
CODE_OF_CONDUCT.md
CONTRIBUTING.md
Dockerfile
LICENSE
Procfile
README.md
SECURITY.md

4
.gitignore vendored
View File

@ -40,8 +40,8 @@ coverage
# misskey
built
elasticsearch
redis
/db
/redis
npm-debug.log
*.pem
run.bat

144
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,144 @@
image: docker.io/rust:slim-bookworm
services:
- name: docker.io/groonga/pgroonga:latest-alpine-12-slim
alias: postgres
- name: docker.io/redis:7-alpine
alias: redis
workflow:
rules:
- if: $CI_PROJECT_PATH == 'firefish/firefish'
when: always
- if: $CI_MERGE_REQUEST_PROJECT_PATH == 'firefish/firefish'
when: always
- when: never
cache:
paths:
- node_modules
# - /usr/local/cargo/registry/index
# - /usr/local/cargo/registry/cache
- target/debug/deps
- target/debug/build
stages:
- test
- build
- dependency
variables:
POSTGRES_DB: 'firefish_db'
POSTGRES_USER: 'firefish'
POSTGRES_PASSWORD: 'password'
POSTGRES_HOST_AUTH_METHOD: 'trust'
DEBIAN_FRONTEND: 'noninteractive'
CARGO_PROFILE_DEV_OPT_LEVEL: '0'
CARGO_PROFILE_DEV_LTO: 'off'
CARGO_PROFILE_DEV_DEBUG: 'none'
default:
before_script:
- apt-get update && apt-get -y upgrade
- apt-get -y --no-install-recommends install curl
- curl -fsSL 'https://deb.nodesource.com/setup_18.x' | bash -
- apt-get install -y --no-install-recommends build-essential clang mold python3 perl nodejs postgresql-client
- corepack enable
- corepack prepare pnpm@latest --activate
- cp .config/ci.yml .config/default.yml
- cp ci/cargo/config.toml /usr/local/cargo/config.toml
- export PGPASSWORD="${POSTGRES_PASSWORD}"
- psql --host postgres --user "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" --command 'CREATE EXTENSION pgroonga'
build_test:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'push' || $CI_PIPELINE_SOURCE == 'merge_request_event'
changes:
paths:
- packages/**/*
- scripts/**/*
- locales/**/*
- package.json
- pnpm-lock.yaml
- Cargo.toml
- Cargo.lock
script:
- pnpm install --frozen-lockfile
- pnpm run build:debug
- pnpm run migrate
container_image_build:
stage: build
image: docker.io/debian:bookworm-slim
services: []
rules:
- if: $CI_COMMIT_BRANCH == 'develop'
changes:
paths:
- packages/**/*
- locales/**/*
- scripts/copy-assets.mjs
- package.json
- pnpm-lock.yaml
- Cargo.toml
- Cargo.lock
- Dockerfile
- .dockerignore
before_script:
- apt-get update && apt-get -y upgrade
- apt-get install -y --no-install-recommends buildah ca-certificates fuse-overlayfs
- buildah login --username "${CI_REGISTRY_USER}" --password "${CI_REGISTRY_PASSWORD}" "${CI_REGISTRY}"
- export IMAGE_TAG="${CI_REGISTRY}/${CI_PROJECT_PATH}/develop:not-for-production"
script:
- buildah build --isolation chroot --device /dev/fuse:rw --security-opt seccomp=unconfined --security-opt apparmor=unconfined --cap-add all --tag "${IMAGE_TAG}" --platform linux/amd64 .
- buildah inspect "${IMAGE_TAG}"
- buildah push "${IMAGE_TAG}"
cargo_unit_test:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event' || $CI_COMMIT_BRANCH == 'develop'
changes:
paths:
- packages/backend-rs/**/*
- packages/macro-rs/**/*
- Cargo.toml
- Cargo.lock
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
when: never
script:
- cargo check --features napi
- pnpm install --frozen-lockfile
- mkdir packages/backend-rs/built
- cp packages/backend-rs/index.js packages/backend-rs/built/index.js
- cp packages/backend-rs/index.d.ts packages/backend-rs/built/index.d.ts
- pnpm --filter='!backend-rs' run build:debug
- cargo test
cargo_clippy:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
changes:
paths:
- packages/backend-rs/**/*
- packages/macro-rs/**/*
- Cargo.toml
- Cargo.lock
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
when: never
script:
- cargo clippy -- -D warnings
renovate:
stage: dependency
image:
name: docker.io/renovate/renovate:37-slim
entrypoint: [""]
rules:
- if: $RENOVATE && $CI_PIPELINE_SOURCE == 'schedule'
services: []
before_script: []
script:
- renovate --platform gitlab --token "${API_TOKEN}" --endpoint "${CI_SERVER_URL}/api/v4" "${CI_PROJECT_PATH}"

View File

@ -26,10 +26,6 @@ RsaSignature2017 implementation by Transmute Industries Inc
License: MIT
https://github.com/transmute-industries/RsaSignature2017/blob/master/LICENSE
Machine learning model for sensitive images by Infinite Red, Inc.
License: MIT
https://github.com/infinitered/nsfwjs/blob/master/LICENSE
Chiptune2.js by Simon Gündling
License: MIT
https://github.com/deskjet/chiptune2.js#license

544
Cargo.lock generated
View File

@ -124,6 +124,17 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
[[package]]
name = "async-channel"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
dependencies = [
"concurrent-queue",
"event-listener",
"futures-core",
]
[[package]]
name = "async-stream"
version = "0.3.5"
@ -207,6 +218,7 @@ dependencies = [
"emojis",
"idna",
"image",
"isahc",
"macro_rs",
"napi",
"napi-build",
@ -218,7 +230,6 @@ dependencies = [
"rand",
"redis",
"regex",
"reqwest",
"rmp-serde",
"sea-orm",
"serde",
@ -445,6 +456,12 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
[[package]]
name = "castaway"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
[[package]]
name = "cc"
version = "1.0.95"
@ -519,6 +536,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@ -534,16 +560,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.6"
@ -661,6 +677,37 @@ dependencies = [
"sha3",
]
[[package]]
name = "curl"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e2161dd6eba090ff1594084e95fd67aeccf04382ffea77999ea94ed42ec67b6"
dependencies = [
"curl-sys",
"libc",
"openssl-probe",
"openssl-sys",
"schannel",
"socket2",
"windows-sys 0.52.0",
]
[[package]]
name = "curl-sys"
version = "0.4.72+curl-8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29cbdc8314c447d11e8fd156dcdd031d9e02a7a976163e396b548c03153bc9ea"
dependencies = [
"cc",
"libc",
"libnghttp2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
"windows-sys 0.52.0",
]
[[package]]
name = "der"
version = "0.7.9"
@ -793,6 +840,15 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
dependencies = [
"instant",
]
[[package]]
name = "fastrand"
version = "2.0.2"
@ -929,6 +985,21 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
[[package]]
name = "futures-lite"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
dependencies = [
"fastrand 1.9.0",
"futures-core",
"futures-io",
"memchr",
"parking",
"pin-project-lite",
"waker-fn",
]
[[package]]
name = "futures-sink"
version = "0.3.30"
@ -995,25 +1066,6 @@ version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
[[package]]
name = "h2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "816ec7294445779408f36fe57bc5b7fc1cf59664059096c65f905c1c61f58069"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "half"
version = "2.4.1"
@ -1108,100 +1160,15 @@ dependencies = [
[[package]]
name = "http"
version = "1.1.0"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "hyper"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"pin-project-lite",
"socket2",
"tokio",
"tower",
"tower-service",
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.60"
@ -1304,6 +1271,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
dependencies = [
"cfg-if",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
@ -1316,10 +1292,31 @@ dependencies = [
]
[[package]]
name = "ipnet"
version = "2.9.0"
name = "isahc"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9"
dependencies = [
"async-channel",
"castaway",
"crossbeam-utils",
"curl",
"curl-sys",
"encoding_rs",
"event-listener",
"futures-lite",
"http",
"log",
"mime",
"once_cell",
"polling",
"slab",
"sluice",
"tracing",
"tracing-futures",
"url",
"waker-fn",
]
[[package]]
name = "itertools"
@ -1417,6 +1414,16 @@ version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
[[package]]
name = "libnghttp2-sys"
version = "0.1.10+1.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "959c25552127d2e1fa72f0e52548ec04fc386e827ba71a7bd01db46a447dc135"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.27.0"
@ -1428,6 +1435,18 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.13"
@ -1591,24 +1610,6 @@ dependencies = [
"libloading",
]
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@ -1881,6 +1882,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "parking"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae"
[[package]]
name = "parking_lot"
version = "0.12.2"
@ -2026,6 +2033,22 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"concurrent-queue",
"libc",
"log",
"pin-project-lite",
"windows-sys 0.48.0",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@ -2330,49 +2353,6 @@ dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10"
dependencies = [
"base64 0.22.0",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile 2.1.2",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg",
]
[[package]]
name = "rgb"
version = "0.8.37"
@ -2523,22 +2503,6 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pemfile"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d"
dependencies = [
"base64 0.22.0",
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beb461507cee2c2ff151784c52762cf4d9ff6a61f3e80968600ed24fa837fa54"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@ -2680,29 +2644,6 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f3cc463c0ef97e11c3461a9d3787412d30e8e7eb907c79180c4a57bf7c04ef"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.22"
@ -2749,18 +2690,6 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@ -2876,6 +2805,17 @@ dependencies = [
"autocfg",
]
[[package]]
name = "sluice"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5"
dependencies = [
"async-channel",
"futures-core",
"futures-io",
]
[[package]]
name = "smallvec"
version = "1.13.2"
@ -2972,7 +2912,7 @@ dependencies = [
"percent-encoding",
"rust_decimal",
"rustls",
"rustls-pemfile 1.0.4",
"rustls-pemfile",
"serde",
"serde_json",
"sha2",
@ -3229,33 +3169,6 @@ dependencies = [
"syn 2.0.60",
]
[[package]]
name = "sync_wrapper"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "system-configuration"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@ -3288,7 +3201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
dependencies = [
"cfg-if",
"fastrand",
"fastrand 2.0.2",
"rustix",
"windows-sys 0.52.0",
]
@ -3410,16 +3323,6 @@ dependencies = [
"syn 2.0.60",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.15"
@ -3431,20 +3334,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
"tracing",
]
[[package]]
name = "toml"
version = "0.8.12"
@ -3490,34 +3379,6 @@ dependencies = [
"winnow 0.6.7",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
[[package]]
name = "tower-service"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
[[package]]
name = "tracing"
version = "0.1.40"
@ -3551,6 +3412,16 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-futures"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"pin-project",
"tracing",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
@ -3576,12 +3447,6 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.17.0"
@ -3695,13 +3560,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "want"
version = "0.3.1"
name = "waker-fn"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
checksum = "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690"
[[package]]
name = "wasi"
@ -3740,18 +3602,6 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
@ -3781,16 +3631,6 @@ version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "web-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.25.4"
@ -4001,16 +3841,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "wyz"
version = "0.5.1"

View File

@ -18,6 +18,7 @@ cuid2 = "0.1.2"
emojis = "0.6.2"
idna = "0.5.0"
image = "0.25.1"
isahc = "1.7.2"
nom-exif = "1.2.0"
once_cell = "1.19.0"
openssl = "0.10.64"
@ -27,7 +28,6 @@ quote = "1.0.36"
rand = "0.8.5"
redis = "0.25.3"
regex = "1.10.4"
reqwest = "0.12.4"
rmp-serde = "1.2.0"
sea-orm = "0.12.15"
serde = "1.0.198"

View File

@ -45,7 +45,7 @@ COPY packages/backend-rs/index.js packages/backend-rs/built/index.js
# Copy in the rest of the files to compile
COPY . ./
RUN NODE_ENV='production' pnpm run --filter firefish-js build
RUN NODE_ENV='production' pnpm run --recursive --parallel --filter '!backend-rs' --filter '!firefish-js' build && pnpm run gulp
RUN NODE_ENV='production' pnpm run --recursive --parallel --filter '!backend-rs' --filter '!firefish-js' build && pnpm run build:assets
# Trim down the dependencies to only those for production
RUN find . -path '*/node_modules/*' -delete && pnpm install --prod --frozen-lockfile

View File

@ -3,7 +3,7 @@ export
.PHONY: pre-commit
pre-commit: format entities napi-index
pre-commit: format entities napi
.PHONY: format
format:
@ -11,11 +11,12 @@ format:
.PHONY: entities
entities:
pnpm --filter=backend run build:debug
pnpm run migrate
$(MAKE) -C ./packages/backend-rs regenerate-entities
.PHONY: napi-index
napi-index:
.PHONY: napi
napi:
$(MAKE) -C ./packages/backend-rs update-index

3
ci/cargo/config.toml Normal file
View File

@ -0,0 +1,3 @@
[target.x86_64-unknown-linux-gnu]
linker = "/usr/bin/clang"
rustflags = ["-C", "link-arg=--ld-path=/usr/bin/mold"]

View File

@ -7,6 +7,8 @@
- Node.js
- pnpm
- Rust toolchain
- Python 3
- Perl
- FFmpeg
- Container runtime
- [Docker](https://docs.docker.com/get-docker/)
@ -31,7 +33,7 @@ You can refer to [local-installation.md](./local-installation.md) to install the
1. Copy example config file
```sh
cp dev/config.example.env dev/config.env
# If you use container runtime other than Docker, you need to modify the "COMPOSE" variable
# If you use container runtime other than Podman, you need to modify the "COMPOSE" variable
# vim dev/config.env
```
1. Create `.config/default.yml` with the following content
@ -51,12 +53,7 @@ You can refer to [local-installation.md](./local-installation.md) to install the
host: localhost
port: 26379
logLevel: [
'error',
'success',
'warning',
'info'
]
maxlogLevel: 'debug' # or 'trace'
```
1. Start database containers
```sh
@ -84,6 +81,19 @@ You can refer to [local-installation.md](./local-installation.md) to install the
DONE * [core boot] Now listening on port 3000 on http://localhost:3000
```
## Update auto-generated files in `package/backend-rs`
You need to install `sea-orm-cli` to regenerate database entities.
```sh
cargo install sea-orm-cli
```
```sh
make entities
make napi
```
## Reset the environment
You can recreate a fresh local Firefish environment by recreating the database containers:

View File

@ -141,12 +141,7 @@ sudo apt install ffmpeg
host: localhost
port: 6379
logLevel: [
'error',
'success',
'warning',
'info'
]
maxLogLevel: 'debug' # or 'trace'
```
## 4. Build and start Firefish

View File

@ -2,6 +2,14 @@
Breaking changes are indicated by the :warning: icon.
## Unreleased
- Adding `lang` to the response of `i` and the request parameter of `i/update`.
## v20240504
- :warning: Removed `release` endpoint.
## v20240424
- Added `antennaLimit` field to the response of `meta` and `admin/meta`, and the request of `admin/update-meta` (optional).

View File

@ -5,6 +5,10 @@ Critical security updates are indicated by the :warning: icon.
- Server administrators should 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.
## [v20240504](https://firefish.dev/firefish/firefish/-/merge_requests/10790/commits)
- Fix bugs
## :warning: [v20240430](https://firefish.dev/firefish/firefish/-/merge_requests/10781/commits)
- Add ability to group similar notifications

View File

@ -1,6 +1,9 @@
BEGIN;
DELETE FROM "migrations" WHERE name IN (
'CreateScheduledNoteCreation1714728200194',
'AddUserProfileLanguage1714888400293',
'DropUnusedIndexes1714643926317',
'AlterAkaType1714099399879',
'AddDriveFileUsage1713451569342',
'ConvertCwVarcharToText1713225866247',
@ -25,6 +28,25 @@ DELETE FROM "migrations" WHERE name IN (
'RemoveNativeUtilsMigration1705877093218'
);
-- create-scheduled-note-creation
DROP TABLE "scheduled_note_creation";
-- drop-unused-indexes
CREATE INDEX "IDX_01f4581f114e0ebd2bbb876f0b" ON "note_reaction" ("createdAt");
CREATE INDEX "IDX_0610ebcfcfb4a18441a9bcdab2" ON "poll" ("userId");
CREATE INDEX "IDX_25dfc71b0369b003a4cd434d0b" ON "note" ("attachedFileTypes");
CREATE INDEX "IDX_2710a55f826ee236ea1a62698f" ON "hashtag" ("mentionedUsersCount");
CREATE INDEX "IDX_4c02d38a976c3ae132228c6fce" ON "hashtag" ("mentionedRemoteUsersCount");
CREATE INDEX "IDX_51c063b6a133a9cb87145450f5" ON "note" ("fileIds");
CREATE INDEX "IDX_54ebcb6d27222913b908d56fd8" ON "note" ("mentions");
CREATE INDEX "IDX_7fa20a12319c7f6dc3aed98c0a" ON "poll" ("userHost");
CREATE INDEX "IDX_88937d94d7443d9a99a76fa5c0" ON "note" ("tags");
CREATE INDEX "IDX_b11a5e627c41d4dc3170f1d370" ON "notification" ("createdAt");
CREATE INDEX "IDX_c8dfad3b72196dd1d6b5db168a" ON "drive_file" ("createdAt");
CREATE INDEX "IDX_d57f9030cd3af7f63ffb1c267c" ON "hashtag" ("attachedUsersCount");
CREATE INDEX "IDX_e5848eac4940934e23dbc17581" ON "drive_file" ("uri");
CREATE INDEX "IDX_fa99d777623947a5b05f394cae" ON "user" ("tags");
-- alter-aka-type
ALTER TABLE "user" RENAME COLUMN "alsoKnownAs" TO "alsoKnownAsOld";
ALTER TABLE "user" ADD COLUMN "alsoKnownAs" text;
@ -747,9 +769,6 @@ CREATE SEQUENCE public.__chart_day__users_id_seq
CACHE 1;
ALTER SEQUENCE public.__chart_day__users_id_seq OWNED BY public.__chart_day__users.id;
-- drop-user-profile-language
ALTER TABLE "user_profile" ADD COLUMN "lang" character varying(32);
-- emoji-moderator
ALTER TABLE "user" DROP COLUMN "emojiModPerm";
DROP TYPE "public"."user_emojimodperm_enum";

View File

@ -24,6 +24,7 @@ Firefish depends on the following software.
- `build-essential` on Debian/Ubuntu Linux
- `base-devel` on Arch Linux
- [Python 3](https://www.python.org/)
- [Perl](https://www.perl.org/)
This document shows an example procedure for installing these dependencies and Firefish on Debian 12. Note that there is much room for customizing the server setup; this document merely demonstrates a simple installation.
@ -325,7 +326,7 @@ cd ~/firefish
- To add custom locales, place them in the `./custom/locales/` directory. If you name your custom locale the same as an existing locale, it will overwrite it. If you give it a unique name, it will be added to the list. Also make sure that the first part of the filename matches the locale you're basing it on. (Example: `en-FOO.yml`)
- To add custom error images, place them in the `./custom/assets/badges` directory, replacing the files already there.
- To add custom sounds, place only mp3 files in the `./custom/assets/sounds` directory.
- To update custom assets without rebuilding, just run `pnpm run gulp`.
- To update custom assets without rebuilding, just run `pnpm run build:assets`.
- To block ChatGPT, CommonCrawl, or other crawlers from indexing your instance, uncomment the respective rules in `./custom/robots.txt`.
## Tips & Tricks

View File

@ -10,24 +10,22 @@ You can control the verbosity of the server log by adding `maxLogLevel` in `.con
### For systemd/pm2 users
Not only Firefish but also Node.js has recently fixed a few security issues:
- https://nodejs.org/en/blog/vulnerability/april-2024-security-releases
- https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2
So, it is highly recommended that you upgrade your Node.js version as well. The new versions are
- Node v18.20.2 (v18.x LTS)
- Node v20.12.2 (v20.x LTS)
- Node v21.7.3 (v21.x)
You can check your Node.js version by this command:
```sh
node --version
```
[Node v22](https://nodejs.org/en/blog/announcements/v22-release-announce) was also released several days ago, but we have not yet tested Firefish with this version.
- You need to install Perl to build Firefish. Since Git depends on Perl in many packaging systems, you probably already have Perl installed on your system. You can check the Perl version by this command:
```sh
perl --version
```
- Not only Firefish but also Node.js has recently fixed a few security issues:
- https://nodejs.org/en/blog/vulnerability/april-2024-security-releases
- https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2
So, it is highly recommended that you upgrade your Node.js version as well. The new versions are
- Node v18.20.2 (v18.x LTS)
- Node v20.12.2 (v20.x LTS)
- Node v21.7.3 (v21.x)
- You can check your Node.js version by this command:
```sh
node --version
```
[Node v22](https://nodejs.org/en/blog/announcements/v22-release-announce) was also released several days ago, but we have not yet tested Firefish with this version.
## v20240413

View File

@ -1,101 +0,0 @@
/**
* Gulp tasks
*/
const fs = require("fs");
const gulp = require("gulp");
const replace = require("gulp-replace");
const terser = require("gulp-terser");
const cssnano = require("gulp-cssnano");
const meta = require("./package.json");
gulp.task("copy:backend:views", () =>
gulp
.src("./packages/backend/src/server/web/views/**/*")
.pipe(gulp.dest("./packages/backend/built/server/web/views")),
);
gulp.task("copy:backend:custom", () =>
gulp
.src("./custom/assets/**/*")
.pipe(gulp.dest("./packages/backend/assets/")),
);
gulp.task("copy:client:fonts", () =>
gulp
.src("./packages/client/node_modules/three/examples/fonts/**/*")
.pipe(gulp.dest("./built/_client_dist_/fonts/")),
);
gulp.task("copy:client:locales", async (cb) => {
fs.mkdirSync("./built/_client_dist_/locales", { recursive: true });
const { default: locales } = await import("./locales/index.mjs");
const v = { _version_: meta.version };
for (const [lang, locale] of Object.entries(locales)) {
fs.writeFileSync(
`./built/_client_dist_/locales/${lang}.${meta.version}.json`,
JSON.stringify({ ...locale, ...v }),
"utf-8",
);
}
cb();
});
gulp.task("build:backend:script", async () => {
const { default: locales } = await import("./locales/index.mjs");
return gulp
.src([
"./packages/backend/src/server/web/boot.js",
"./packages/backend/src/server/web/bios.js",
"./packages/backend/src/server/web/cli.js",
])
.pipe(replace("SUPPORTED_LANGS", JSON.stringify(Object.keys(locales))))
.pipe(
terser({
toplevel: true,
}),
)
.pipe(gulp.dest("./packages/backend/built/server/web/"));
});
gulp.task("build:backend:style", () => {
return gulp
.src([
"./packages/backend/src/server/web/style.css",
"./packages/backend/src/server/web/bios.css",
"./packages/backend/src/server/web/cli.css",
])
.pipe(
cssnano({
zindex: false,
}),
)
.pipe(gulp.dest("./packages/backend/built/server/web/"));
});
gulp.task(
"build",
gulp.parallel(
"copy:client:locales",
"copy:backend:views",
"copy:backend:custom",
"build:backend:script",
"build:backend:style",
"copy:client:fonts",
),
);
gulp.task("default", gulp.task("build"));
gulp.task("watch", () => {
gulp.watch(
["./packages/*/src/**/*"],
{ ignoreInitial: false },
gulp.task("build"),
);
});

View File

@ -766,6 +766,9 @@ confirmToUnclipAlreadyClippedNote: "This post is already part of the \"{name}\"
public: "Public"
i18nInfo: "Firefish is being translated into various languages by volunteers. You
can help at {link}."
i18nServerInfo: "New clients will be in {language} by default."
i18nServerChange: "Use {language} instead."
i18nServerSet: "Use {language} for new clients."
manageAccessTokens: "Manage access tokens"
accountInfo: "Account Info"
notesCount: "Number of posts"

View File

@ -685,6 +685,9 @@ unclip: "クリップ解除"
confirmToUnclipAlreadyClippedNote: "この投稿はすでにクリップ「{name}」に含まれています。投稿をこのクリップから除外しますか?"
public: "公開"
i18nInfo: "Firefishは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。"
i18nServerInfo: "新しい端末では{language}が既定の言語になります。"
i18nServerChange: "{language}に変更する。"
i18nServerSet: "新しい端末での表示言語を{language}にします。"
manageAccessTokens: "アクセストークンの管理"
accountInfo: "アカウント情報"
notesCount: "投稿の数"

View File

@ -667,6 +667,9 @@ unclip: "移除便签"
confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。您想要将本帖从该便签中移除吗?"
public: "公开"
i18nInfo: "Firefish 已经被志愿者们翻译成了各种语言。如果您也有兴趣,可以通过 {link} 帮助翻译。"
i18nServerInfo: "新客户端将默认使用 {language}。"
i18nServerChange: "改为 {language}。"
i18nServerSet: "设定新客户端使用 {language}。"
manageAccessTokens: "管理访问令牌"
accountInfo: "账号信息"
notesCount: "帖子数量"

View File

@ -661,6 +661,9 @@ unclip: "解除摘錄"
confirmToUnclipAlreadyClippedNote: "此貼文已包含在摘錄「{name}」中。 你想將貼文從這個摘錄中排除嗎?"
public: "公開"
i18nInfo: "Firefish已經被志願者們翻譯成各種語言版本如果想要幫忙的話可以進入{link}幫助翻譯。"
i18nServerInfo: "新客戶端將默認使用 {language}。"
i18nServerChange: "改為 {language}。"
i18nServerSet: "設定新客戶端使用 {language}。"
manageAccessTokens: "管理存取權杖"
accountInfo: "帳戶資訊"
notesCount: "貼文數量"

View File

@ -1,6 +1,6 @@
{
"name": "firefish",
"version": "20240430",
"version": "20240504",
"repository": {
"type": "git",
"url": "https://firefish.dev/firefish/firefish.git"
@ -9,14 +9,15 @@
"private": true,
"scripts": {
"rebuild": "pnpm run clean && pnpm run build",
"build": "pnpm node ./scripts/build.mjs && pnpm run gulp",
"build": "pnpm node ./scripts/build.mjs && pnpm run build:assets",
"build:assets": "pnpm node ./scripts/copy-assets.mjs",
"build:debug": "pnpm run clean && pnpm node ./scripts/dev-build.mjs && pnpm run build:assets",
"start": "pnpm --filter backend run start",
"start:container": "pnpm run gulp && pnpm run migrate && pnpm run start",
"start:container": "pnpm run build:assets && pnpm run migrate && pnpm run start",
"start:test": "pnpm --filter backend run start:test",
"init": "pnpm run migrate",
"migrate": "pnpm --filter backend run migration:run",
"revertmigration": "pnpm --filter backend run migration:revert",
"gulp": "gulp build",
"watch": "pnpm run dev",
"dev": "pnpm node ./scripts/dev.mjs",
"dev:staging": "NODE_OPTIONS=--max_old_space_size=3072 NODE_ENV=development pnpm run build && pnpm run start",
@ -24,7 +25,6 @@
"lint:ts": "pnpm --filter !firefish-js -r --parallel run lint",
"lint:rs": "cargo clippy --fix --allow-dirty --allow-staged && cargo fmt --all --",
"debug": "pnpm run build:debug && pnpm run start",
"build:debug": "pnpm run clean && pnpm node ./scripts/dev-build.mjs && pnpm run gulp",
"mocha": "pnpm --filter backend run mocha",
"test": "pnpm run test:ts && pnpm run test:rs",
"test:ts": "pnpm run mocha",
@ -38,10 +38,6 @@
"clean-all": "pnpm run clean && pnpm run clean-cargo && pnpm run clean-npm"
},
"dependencies": {
"gulp": "4.0.2",
"gulp-cssnano": "2.1.3",
"gulp-replace": "1.1.4",
"gulp-terser": "2.1.0",
"js-yaml": "4.1.0"
},
"devDependencies": {

View File

@ -7,6 +7,7 @@ rust-version = "1.74"
[features]
default = []
napi = ["dep:napi", "dep:napi-derive"]
ci = []
[lib]
crate-type = ["cdylib", "lib"]
@ -25,13 +26,13 @@ cuid2 = { workspace = true }
emojis = { workspace = true }
idna = { workspace = true }
image = { workspace = true }
isahc = { workspace = true }
nom-exif = { workspace = true }
once_cell = { workspace = true }
openssl = { workspace = true, features = ["vendored"] }
rand = { workspace = true }
redis = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] }
rmp-serde = { workspace = true }
sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
serde = { workspace = true, features = ["derive"] }

View File

@ -214,19 +214,26 @@ export function stringToAcct(acct: string): Acct
export function acctToString(acct: Acct): string
export function addNoteToAntenna(antennaId: string, note: Note): void
/**
* @param host punycoded instance host
* @returns whether the given host should be blocked
*/
* Checks if a server is blocked.
*
* ## Argument
* `host` - punycoded instance host
*/
export function isBlockedServer(host: string): Promise<boolean>
/**
* @param host punycoded instance host
* @returns whether the given host should be limited
*/
* Checks if a server is silenced.
*
* ## Argument
* `host` - punycoded instance host
*/
export function isSilencedServer(host: string): Promise<boolean>
/**
* @param host punycoded instance host
* @returns whether the given host is allowlisted (this is always true if private mode is disabled)
*/
* Checks if a server is allowlisted.
* Returns `Ok(true)` if private mode is disabled.
*
* ## Argument
* `host` - punycoded instance host
*/
export function isAllowedServer(host: string): Promise<boolean>
/** TODO: handle name collisions better */
export interface NoteLikeForCheckWordMute {
@ -261,6 +268,8 @@ export interface NoteLikeForGetNoteSummary {
hasPoll: boolean
}
export function getNoteSummary(note: NoteLikeForGetNoteSummary): string
export function isSafeUrl(url: string): boolean
export function latestVersion(): Promise<string>
export function toMastodonId(firefishId: string): string | null
export function fromMastodonId(mastodonId: string): string | null
export function fetchMeta(useCache: boolean): Promise<Meta>
@ -1121,6 +1130,7 @@ export interface UserProfile {
preventAiLearning: boolean
isIndexable: boolean
mutedPatterns: Array<string>
lang: string | null
}
export interface UserPublickey {
userId: string
@ -1147,6 +1157,106 @@ export interface Webhook {
latestStatus: number | null
}
export function initializeRustLogger(): void
export function fetchNodeinfo(host: string): Promise<Nodeinfo>
export function nodeinfo_2_1(): Promise<any>
export function nodeinfo_2_0(): Promise<any>
/** NodeInfo schema version 2.0. https://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0 */
export interface Nodeinfo {
/** The schema version, must be 2.0. */
version: string
/** Metadata about server software in use. */
software: Software20
/** The protocols supported on this server. */
protocols: Array<Protocol>
/** The third party sites this server can connect to via their application API. */
services: Services
/** Whether this server allows open self-registration. */
openRegistrations: boolean
/** Usage statistics for this server. */
usage: Usage
/** Free form key value pairs for software specific values. Clients should not rely on any specific key present. */
metadata: Record<string, any>
}
/** Metadata about server software in use (version 2.0). */
export interface Software20 {
/** The canonical name of this server software. */
name: string
/** The version of this server software. */
version: string
}
export enum Protocol {
Activitypub = 'activitypub',
Buddycloud = 'buddycloud',
Dfrn = 'dfrn',
Diaspora = 'diaspora',
Libertree = 'libertree',
Ostatus = 'ostatus',
Pumpio = 'pumpio',
Tent = 'tent',
Xmpp = 'xmpp',
Zot = 'zot'
}
/** The third party sites this server can connect to via their application API. */
export interface Services {
/** The third party sites this server can retrieve messages from for combined display with regular traffic. */
inbound: Array<Inbound>
/** The third party sites this server can publish messages to on the behalf of a user. */
outbound: Array<Outbound>
}
/** The third party sites this server can retrieve messages from for combined display with regular traffic. */
export enum Inbound {
Atom1 = 'atom1',
Gnusocial = 'gnusocial',
Imap = 'imap',
Pnut = 'pnut',
Pop3 = 'pop3',
Pumpio = 'pumpio',
Rss2 = 'rss2',
Twitter = 'twitter'
}
/** The third party sites this server can publish messages to on the behalf of a user. */
export enum Outbound {
Atom1 = 'atom1',
Blogger = 'blogger',
Buddycloud = 'buddycloud',
Diaspora = 'diaspora',
Dreamwidth = 'dreamwidth',
Drupal = 'drupal',
Facebook = 'facebook',
Friendica = 'friendica',
Gnusocial = 'gnusocial',
Google = 'google',
Insanejournal = 'insanejournal',
Libertree = 'libertree',
Linkedin = 'linkedin',
Livejournal = 'livejournal',
Mediagoblin = 'mediagoblin',
Myspace = 'myspace',
Pinterest = 'pinterest',
Pnut = 'pnut',
Posterous = 'posterous',
Pumpio = 'pumpio',
Redmatrix = 'redmatrix',
Rss2 = 'rss2',
Smtp = 'smtp',
Tent = 'tent',
Tumblr = 'tumblr',
Twitter = 'twitter',
Wordpress = 'wordpress',
Xmpp = 'xmpp'
}
/** Usage statistics for this server. */
export interface Usage {
users: Users
localPosts: number | null
localComments: number | null
}
/** statistics about the users of this server. */
export interface Users {
total: number | null
activeHalfyear: number | null
activeMonth: number | null
}
export function watchNote(watcherId: string, noteAuthorId: string, noteId: string): Promise<void>
export function unwatchNote(watcherId: string, noteId: string): Promise<void>
export function publishToChannelStream(channelId: string, userId: string): void

View File

@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
module.exports.SECOND = SECOND
module.exports.MINUTE = MINUTE
@ -339,6 +339,8 @@ module.exports.safeForSql = safeForSql
module.exports.formatMilliseconds = formatMilliseconds
module.exports.getImageSizeFromUrl = getImageSizeFromUrl
module.exports.getNoteSummary = getNoteSummary
module.exports.isSafeUrl = isSafeUrl
module.exports.latestVersion = latestVersion
module.exports.toMastodonId = toMastodonId
module.exports.fromMastodonId = fromMastodonId
module.exports.fetchMeta = fetchMeta
@ -363,6 +365,12 @@ module.exports.UserEmojimodpermEnum = UserEmojimodpermEnum
module.exports.UserProfileFfvisibilityEnum = UserProfileFfvisibilityEnum
module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotificationtypesEnum
module.exports.initializeRustLogger = initializeRustLogger
module.exports.fetchNodeinfo = fetchNodeinfo
module.exports.nodeinfo_2_1 = nodeinfo_2_1
module.exports.nodeinfo_2_0 = nodeinfo_2_0
module.exports.Protocol = Protocol
module.exports.Inbound = Inbound
module.exports.Outbound = Outbound
module.exports.watchNote = watchNote
module.exports.unwatchNote = unwatchNote
module.exports.publishToChannelStream = publishToChannelStream

View File

@ -0,0 +1,290 @@
use crate::database::{redis_conn, redis_key};
use redis::{Commands, RedisError};
use serde::{Deserialize, Serialize};
#[derive(strum::Display, Debug)]
pub enum Category {
#[strum(serialize = "fetchUrl")]
FetchUrl,
#[cfg(test)]
#[strum(serialize = "usedOnlyForTesting")]
Test,
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Redis error: {0}")]
RedisError(#[from] RedisError),
#[error("Data serialization error: {0}")]
SerializeError(#[from] rmp_serde::encode::Error),
#[error("Data deserialization error: {0}")]
DeserializeError(#[from] rmp_serde::decode::Error),
}
#[inline]
fn prefix_key(key: &str) -> String {
redis_key(format!("cache:{}", key))
}
#[inline]
fn categorize(category: Category, key: &str) -> String {
format!("{}:{}", category, key)
}
#[inline]
fn wildcard(category: Category) -> String {
prefix_key(&categorize(category, "*"))
}
/// Sets a Redis cache.
///
/// This overwrites the exsisting cache with the same key.
///
/// ## Arguments
///
/// * `key` - key (will be prefixed automatically)
/// * `value` - (de)serializable value
/// * `expire_seconds` - TTL
///
/// ## Example
///
/// ```
/// use backend_rs::database::cache;
/// let key = "apple";
/// let data = "I want to cache this string".to_string();
///
/// // caches the data for 10 seconds
/// cache::set(key, &data, 10);
///
/// // get the cache
/// let cached_data = cache::get::<String>(key).unwrap();
/// assert_eq!(data, cached_data.unwrap());
/// ```
pub fn set<V: for<'a> Deserialize<'a> + Serialize>(
key: &str,
value: &V,
expire_seconds: u64,
) -> Result<(), Error> {
redis_conn()?.set_ex(
prefix_key(key),
rmp_serde::encode::to_vec(&value)?,
expire_seconds,
)?;
Ok(())
}
/// Gets a Redis cache.
///
/// If the Redis connection is fine, this returns `Ok(data)` where `data`
/// is the cached value. Returns `Ok(None)` if there is no value corresponding to `key`.
///
/// ## Arguments
///
/// * `key` - key (will be prefixed automatically)
///
/// ## Example
///
/// ```
/// use backend_rs::database::cache;
///
/// let key = "banana";
/// let data = "I want to cache this string".to_string();
///
/// // set cache
/// cache::set(key, &data, 10).unwrap();
///
/// // get cache
/// let cached_data = cache::get::<String>(key).unwrap();
/// assert_eq!(data, cached_data.unwrap());
///
/// // get nonexistent (or expired) cache
/// let no_cache = cache::get::<String>("nonexistent").unwrap();
/// assert!(no_cache.is_none());
/// ```
pub fn get<V: for<'a> Deserialize<'a> + Serialize>(key: &str) -> Result<Option<V>, Error> {
let serialized_value: Option<Vec<u8>> = redis_conn()?.get(prefix_key(key))?;
Ok(match serialized_value {
Some(v) => Some(rmp_serde::from_slice::<V>(v.as_ref())?),
None => None,
})
}
/// Deletes a Redis cache.
///
/// If the Redis connection is fine, this returns `Ok(())`
/// regardless of whether the cache exists.
///
/// ## Arguments
///
/// * `key` - key (will be prefixed automatically)
///
/// ## Example
///
/// ```
/// use backend_rs::database::cache::{set, get, delete};
///
/// let key = "chocolate";
/// let value = "I want to cache this string".to_string();
///
/// // set cache
/// set(key, &value, 10).unwrap();
///
/// // delete the cache
/// delete("foo").unwrap();
/// delete("nonexistent").unwrap(); // this is okay
///
/// // the cache is gone
/// let cached_value = get::<String>("foo").unwrap();
/// assert!(cached_value.is_none());
/// ```
pub fn delete(key: &str) -> Result<(), Error> {
Ok(redis_conn()?.del(prefix_key(key))?)
}
/// Sets a Redis cache under a `category`.
///
/// The usage is the same as [set], except that you need to
/// use [get_one] and [delete_one] to get/delete the cache.
///
/// ## Arguments
///
/// * `category` - one of [Category]
/// * `key` - key (will be prefixed automatically)
/// * `value` - (de)serializable value
/// * `expire_seconds` - TTL
pub fn set_one<V: for<'a> Deserialize<'a> + Serialize>(
category: Category,
key: &str,
value: &V,
expire_seconds: u64,
) -> Result<(), Error> {
set(&categorize(category, key), value, expire_seconds)
}
/// Gets a Redis cache under a `category`.
///
/// The usage is basically the same as [get].
///
/// ## Arguments
///
/// * `category` - one of [Category]
/// * `key` - key (will be prefixed automatically)
pub fn get_one<V: for<'a> Deserialize<'a> + Serialize>(
category: Category,
key: &str,
) -> Result<Option<V>, Error> {
get(&categorize(category, key))
}
/// Deletes a Redis cache under a `category`.
///
/// The usage is basically the same as [delete].
///
/// ## Arguments
///
/// * `category` - one of [Category]
/// * `key` - key (will be prefixed automatically)
pub fn delete_one(category: Category, key: &str) -> Result<(), Error> {
delete(&categorize(category, key))
}
/// Deletes all Redis caches under a `category`.
///
/// ## Arguments
///
/// * `category` - one of [Category]
pub fn delete_all(category: Category) -> Result<(), Error> {
let mut redis = redis_conn()?;
let keys: Vec<Vec<u8>> = redis.keys(wildcard(category))?;
if !keys.is_empty() {
redis.del(keys)?
}
Ok(())
}
// TODO: set_all(), get_all()
#[cfg(test)]
mod unit_test {
use crate::database::cache::delete_one;
use super::{delete_all, get, get_one, set, set_one, Category::Test};
use pretty_assertions::assert_eq;
#[test]
fn set_get_expire() {
#[derive(serde::Deserialize, serde::Serialize, PartialEq, Debug)]
struct Data {
id: u32,
kind: String,
}
let key_1 = "CARGO_TEST_CACHE_KEY_1";
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 key_3 = "CARGO_TEST_CACHE_KEY_3";
let value_3 = Data {
id: 1000000007,
kind: "prime number".to_string(),
};
set(key_1, &value_1, 1).unwrap();
set(key_2, &value_2, 1).unwrap();
set(key_3, &value_3, 1).unwrap();
let cached_value_1: Vec<i32> = get(key_1).unwrap().unwrap();
let cached_value_2: String = get(key_2).unwrap().unwrap();
let cached_value_3: Data = get(key_3).unwrap().unwrap();
assert_eq!(value_1, cached_value_1);
assert_eq!(value_2, cached_value_2);
assert_eq!(value_3, cached_value_3);
// wait for the cache to expire
std::thread::sleep(std::time::Duration::from_millis(1100));
let expired_value_1: Option<Vec<i32>> = get(key_1).unwrap();
let expired_value_2: Option<Vec<i32>> = get(key_2).unwrap();
let expired_value_3: Option<Vec<i32>> = get(key_3).unwrap();
assert!(expired_value_1.is_none());
assert!(expired_value_2.is_none());
assert!(expired_value_3.is_none());
}
#[test]
fn use_category() {
let key_1 = "fire";
let key_2 = "fish";
let key_3 = "awawa";
let value_1 = "hello".to_string();
let value_2 = 998244353u32;
let value_3 = 'あ';
set_one(Test, key_1, &value_1, 5 * 60).unwrap();
set_one(Test, key_2, &value_2, 5 * 60).unwrap();
set_one(Test, key_3, &value_3, 5 * 60).unwrap();
assert_eq!(get_one::<String>(Test, key_1).unwrap().unwrap(), value_1);
assert_eq!(get_one::<u32>(Test, key_2).unwrap().unwrap(), value_2);
assert_eq!(get_one::<char>(Test, key_3).unwrap().unwrap(), value_3);
delete_one(Test, key_1).unwrap();
assert!(get_one::<String>(Test, key_1).unwrap().is_none());
assert!(get_one::<u32>(Test, key_2).unwrap().is_some());
assert!(get_one::<char>(Test, key_3).unwrap().is_some());
delete_all(Test).unwrap();
assert!(get_one::<String>(Test, key_1).unwrap().is_none());
assert!(get_one::<u32>(Test, key_2).unwrap().is_none());
assert!(get_one::<char>(Test, key_3).unwrap().is_none());
}
}

View File

@ -2,5 +2,6 @@ pub use postgresql::db_conn;
pub use redis::key as redis_key;
pub use redis::redis_conn;
pub mod cache;
pub mod postgresql;
pub mod redis;

View File

@ -1,10 +1,10 @@
use crate::misc::meta::fetch_meta;
use sea_orm::DbErr;
/**
* @param host punycoded instance host
* @returns whether the given host should be blocked
*/
/// Checks if a server is blocked.
///
/// ## Argument
/// `host` - punycoded instance host
#[crate::export]
pub async fn is_blocked_server(host: &str) -> Result<bool, DbErr> {
Ok(fetch_meta(true)
@ -16,10 +16,10 @@ pub async fn is_blocked_server(host: &str) -> Result<bool, DbErr> {
}))
}
/**
* @param host punycoded instance host
* @returns whether the given host should be limited
*/
/// Checks if a server is silenced.
///
/// ## Argument
/// `host` - punycoded instance host
#[crate::export]
pub async fn is_silenced_server(host: &str) -> Result<bool, DbErr> {
Ok(fetch_meta(true)
@ -31,10 +31,11 @@ pub async fn is_silenced_server(host: &str) -> Result<bool, DbErr> {
}))
}
/**
* @param host punycoded instance host
* @returns whether the given host is allowlisted (this is always true if private mode is disabled)
*/
/// Checks if a server is allowlisted.
/// Returns `Ok(true)` if private mode is disabled.
///
/// ## Argument
/// `host` - punycoded instance host
#[crate::export]
pub async fn is_allowed_server(host: &str) -> Result<bool, DbErr> {
let meta = fetch_meta(true).await?;

View File

@ -1,6 +1,7 @@
use crate::misc::redis_cache::{get_cache, set_cache, CacheError};
use crate::database::cache;
use crate::util::http_client;
use image::{io::Reader, ImageError, ImageFormat};
use isahc::ReadResponseExt;
use nom_exif::{parse_jpeg_exif, EntryValue, ExifTag};
use std::io::Cursor;
use tokio::sync::Mutex;
@ -8,9 +9,13 @@ use tokio::sync::Mutex;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Redis cache error: {0}")]
CacheErr(#[from] CacheError),
#[error("Reqwest error: {0}")]
ReqwestErr(#[from] reqwest::Error),
CacheErr(#[from] cache::Error),
#[error("HTTP client aquisition error: {0}")]
HttpClientErr(#[from] http_client::Error),
#[error("Isahc error: {0}")]
IsahcErr(#[from] isahc::Error),
#[error("HTTP error: {0}")]
HttpErr(String),
#[error("Image decoding error: {0}")]
ImageErr(#[from] ImageError),
#[error("Image decoding error: {0}")]
@ -50,11 +55,10 @@ pub async fn get_image_size_from_url(url: &str) -> Result<ImageSize, Error> {
{
let _ = MTX_GUARD.lock().await;
let key = format!("fetchImage:{}", url);
attempted = get_cache::<bool>(&key)?.is_some();
attempted = cache::get_one::<bool>(cache::Category::FetchUrl, url)?.is_some();
if !attempted {
set_cache(&key, &true, 10 * 60)?;
cache::set_one(cache::Category::FetchUrl, url, &true, 10 * 60)?;
}
}
@ -65,7 +69,16 @@ pub async fn get_image_size_from_url(url: &str) -> Result<ImageSize, Error> {
tracing::info!("retrieving image size from {}", url);
let image_bytes = http_client()?.get(url).send().await?.bytes().await?;
let mut response = http_client::client()?.get(url)?;
if !response.status().is_success() {
tracing::info!("status: {}", response.status());
tracing::debug!("response body: {:#?}", response.body());
return Err(Error::HttpErr(format!("Failed to get image from {}", url)));
}
let image_bytes = response.bytes()?;
let reader = Reader::new(Cursor::new(&image_bytes)).with_guessed_format()?;
let format = reader.format();
@ -109,7 +122,7 @@ pub async fn get_image_size_from_url(url: &str) -> Result<ImageSize, Error> {
#[cfg(test)]
mod unit_test {
use super::{get_image_size_from_url, ImageSize};
use crate::misc::redis_cache::delete_cache;
use crate::database::cache;
use pretty_assertions::assert_eq;
#[tokio::test]
@ -124,17 +137,8 @@ mod unit_test {
let gif_url = "https://firefish.dev/firefish/firefish/-/raw/b9c3dfbd3d473cb2cee20c467eeae780bc401271/packages/backend/test/resources/anime.gif";
let mp3_url = "https://firefish.dev/firefish/firefish/-/blob/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/sounds/aisha/1.mp3";
// Delete caches in case you run this test multiple times
// (should be disabled in CI tasks)
delete_cache(&format!("fetchImage:{}", png_url_1)).unwrap();
delete_cache(&format!("fetchImage:{}", png_url_2)).unwrap();
delete_cache(&format!("fetchImage:{}", png_url_3)).unwrap();
delete_cache(&format!("fetchImage:{}", rotated_jpeg_url)).unwrap();
delete_cache(&format!("fetchImage:{}", webp_url_1)).unwrap();
delete_cache(&format!("fetchImage:{}", webp_url_2)).unwrap();
delete_cache(&format!("fetchImage:{}", ico_url)).unwrap();
delete_cache(&format!("fetchImage:{}", gif_url)).unwrap();
delete_cache(&format!("fetchImage:{}", mp3_url)).unwrap();
// delete caches in case you run this test multiple times
cache::delete_all(cache::Category::FetchUrl).unwrap();
let png_size_1 = ImageSize {
width: 1024,
@ -197,4 +201,15 @@ mod unit_test {
assert_eq!(gif_size, get_image_size_from_url(gif_url).await.unwrap());
assert!(get_image_size_from_url(mp3_url).await.is_err());
}
#[tokio::test]
async fn too_many_attempts() {
let url = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/splash.png";
// delete caches in case you run this test multiple times
cache::delete_one(cache::Category::FetchUrl, url).unwrap();
assert!(get_image_size_from_url(url).await.is_ok());
assert!(get_image_size_from_url(url).await.is_err());
}
}

View File

@ -0,0 +1,34 @@
#[crate::export]
pub fn is_safe_url(url: &str) -> bool {
if let Ok(url) = url.parse::<url::Url>() {
if url.host_str().unwrap_or_default() == "unix"
|| !["http", "https"].contains(&url.scheme())
|| ![None, Some(80), Some(443)].contains(&url.port())
{
return false;
}
true
} else {
false
}
}
#[cfg(test)]
mod unit_test {
use super::is_safe_url;
#[test]
fn safe_url() {
assert!(is_safe_url("http://firefish.dev/firefish/firefish"));
assert!(is_safe_url("https://firefish.dev/firefish/firefish"));
assert!(is_safe_url("http://firefish.dev:80/firefish/firefish"));
assert!(is_safe_url("https://firefish.dev:80/firefish/firefish"));
assert!(is_safe_url("http://firefish.dev:443/firefish/firefish"));
assert!(is_safe_url("https://firefish.dev:443/firefish/firefish"));
assert!(!is_safe_url("https://unix/firefish/firefish"));
assert!(!is_safe_url("https://firefish.dev:35/firefish/firefish"));
assert!(!is_safe_url("ftp://firefish.dev/firefish/firefish"));
assert!(!is_safe_url("nyaa"));
assert!(!is_safe_url(""));
}
}

View File

@ -0,0 +1,108 @@
use crate::database::cache;
use crate::util::http_client;
use isahc::ReadResponseExt;
use serde::{Deserialize, Serialize};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Cache error: {0}")]
CacheErr(#[from] cache::Error),
#[error("Isahc error: {0}")]
IsahcErr(#[from] isahc::Error),
#[error("HTTP client aquisition error: {0}")]
HttpClientErr(#[from] http_client::Error),
#[error("HTTP error: {0}")]
HttpErr(String),
#[error("Response parsing error: {0}")]
IoErr(#[from] std::io::Error),
#[error("Failed to deserialize JSON: {0}")]
JsonErr(#[from] serde_json::Error),
}
const UPSTREAM_PACKAGE_JSON_URL: &str =
"https://firefish.dev/firefish/firefish/-/raw/main/package.json";
async fn get_latest_version() -> Result<String, Error> {
#[derive(Debug, Deserialize, Serialize)]
struct Response {
version: String,
}
let mut response = http_client::client()?.get(UPSTREAM_PACKAGE_JSON_URL)?;
if !response.status().is_success() {
tracing::info!("status: {}", response.status());
tracing::debug!("response body: {:#?}", response.body());
return Err(Error::HttpErr(
"Failed to fetch version from Firefish GitLab".to_string(),
));
}
let res_parsed: Response = serde_json::from_str(&response.text()?)?;
Ok(res_parsed.version)
}
#[crate::export]
pub async fn latest_version() -> Result<String, Error> {
let version: Option<String> =
cache::get_one(cache::Category::FetchUrl, UPSTREAM_PACKAGE_JSON_URL)?;
if let Some(v) = version {
tracing::trace!("use cached value: {}", v);
Ok(v)
} else {
tracing::trace!("cache is expired, fetching the latest version");
let fetched_version = get_latest_version().await?;
tracing::trace!("fetched value: {}", fetched_version);
cache::set_one(
cache::Category::FetchUrl,
UPSTREAM_PACKAGE_JSON_URL,
&fetched_version,
3 * 60 * 60,
)?;
Ok(fetched_version)
}
}
#[cfg(test)]
mod unit_test {
use super::{latest_version, UPSTREAM_PACKAGE_JSON_URL};
use crate::database::cache;
fn validate_version(version: String) {
// version: YYYYMMDD or YYYYMMDD-X
assert!(version.len() >= 8);
assert!(version[..8].chars().all(|c| c.is_ascii_digit()));
// YYYY
assert!(&version[..4] >= "2024");
// MM
assert!(&version[4..6] >= "01");
assert!(&version[4..6] <= "12");
// DD
assert!(&version[6..8] >= "01");
assert!(&version[6..8] <= "31");
// -X
if version.len() > 8 {
assert!(version.chars().nth(8).unwrap() == '-');
assert!(version[9..].chars().all(|c| c.is_ascii_digit()));
}
}
#[tokio::test]
async fn check_version() {
// delete caches in case you run this test multiple times
cache::delete_one(cache::Category::FetchUrl, UPSTREAM_PACKAGE_JSON_URL).unwrap();
// fetch from firefish.dev
validate_version(latest_version().await.unwrap());
// use cache
validate_version(latest_version().await.unwrap());
}
}

View File

@ -8,10 +8,11 @@ pub mod escape_sql;
pub mod format_milliseconds;
pub mod get_image_size;
pub mod get_note_summary;
pub mod is_safe_url;
pub mod latest_version;
pub mod mastodon_id;
pub mod meta;
pub mod nyaify;
pub mod password;
pub mod reaction;
pub mod redis_cache;
pub mod remove_old_attestation_challenges;

View File

@ -1,94 +0,0 @@
use crate::database::{redis_conn, redis_key};
use redis::{Commands, RedisError};
use serde::{Deserialize, Serialize};
#[derive(thiserror::Error, Debug)]
pub enum CacheError {
#[error("Redis error: {0}")]
RedisError(#[from] RedisError),
#[error("Data serialization error: {0}")]
SerializeError(#[from] rmp_serde::encode::Error),
#[error("Data deserialization error: {0}")]
DeserializeError(#[from] rmp_serde::decode::Error),
}
fn prefix_key(key: &str) -> String {
redis_key(format!("cache:{}", key))
}
pub fn set_cache<V: for<'a> Deserialize<'a> + Serialize>(
key: &str,
value: &V,
expire_seconds: u64,
) -> Result<(), CacheError> {
redis_conn()?.set_ex(
prefix_key(key),
rmp_serde::encode::to_vec(&value)?,
expire_seconds,
)?;
Ok(())
}
pub fn get_cache<V: for<'a> Deserialize<'a> + Serialize>(
key: &str,
) -> Result<Option<V>, CacheError> {
let serialized_value: Option<Vec<u8>> = redis_conn()?.get(prefix_key(key))?;
Ok(match serialized_value {
Some(v) => Some(rmp_serde::from_slice::<V>(v.as_ref())?),
None => None,
})
}
pub fn delete_cache(key: &str) -> Result<(), CacheError> {
Ok(redis_conn()?.del(prefix_key(key))?)
}
#[cfg(test)]
mod unit_test {
use super::{get_cache, set_cache};
use pretty_assertions::assert_eq;
#[test]
fn set_get_expire() {
#[derive(serde::Deserialize, serde::Serialize, PartialEq, Debug)]
struct Data {
id: u32,
kind: String,
}
let key_1 = "CARGO_TEST_CACHE_KEY_1";
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 key_3 = "CARGO_TEST_CACHE_KEY_3";
let value_3 = Data {
id: 1000000007,
kind: "prime number".to_string(),
};
set_cache(key_1, &value_1, 1).unwrap();
set_cache(key_2, &value_2, 1).unwrap();
set_cache(key_3, &value_3, 1).unwrap();
let cached_value_1: Vec<i32> = get_cache(key_1).unwrap().unwrap();
let cached_value_2: String = get_cache(key_2).unwrap().unwrap();
let cached_value_3: Data = get_cache(key_3).unwrap().unwrap();
assert_eq!(value_1, cached_value_1);
assert_eq!(value_2, cached_value_2);
assert_eq!(value_3, cached_value_3);
// wait for the cache to expire
std::thread::sleep(std::time::Duration::from_millis(1100));
let expired_value_1: Option<Vec<i32>> = get_cache(key_1).unwrap();
let expired_value_2: Option<Vec<i32>> = get_cache(key_2).unwrap();
let expired_value_3: Option<Vec<i32>> = get_cache(key_3).unwrap();
assert!(expired_value_1.is_none());
assert!(expired_value_2.is_none());
assert!(expired_value_3.is_none());
}
}

View File

@ -78,6 +78,7 @@ pub struct Model {
pub is_indexable: bool,
#[sea_orm(column_name = "mutedPatterns")]
pub muted_patterns: Vec<String>,
pub lang: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@ -1,3 +1,4 @@
pub mod log;
pub mod nodeinfo;
pub mod note;
pub mod stream;

View File

@ -0,0 +1,161 @@
use crate::service::nodeinfo::schema::*;
use crate::util::http_client;
use isahc::AsyncReadResponseExt;
use serde::{Deserialize, Serialize};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Http client aquisition error: {0}")]
HttpClientErr(#[from] http_client::Error),
#[error("Http error: {0}")]
HttpErr(#[from] isahc::Error),
#[error("Bad status: {0}")]
BadStatus(String),
#[error("Failed to parse response body as text: {0}")]
ResponseErr(#[from] std::io::Error),
#[error("Failed to parse response body as json: {0}")]
JsonErr(#[from] serde_json::Error),
#[error("No nodeinfo provided")]
MissingNodeinfo,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct NodeinfoLinks {
links: Vec<NodeinfoLink>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct NodeinfoLink {
rel: String,
href: String,
}
#[inline]
fn wellknown_nodeinfo_url(host: &str) -> String {
format!("https://{}/.well-known/nodeinfo", host)
}
async fn fetch_nodeinfo_links(host: &str) -> Result<NodeinfoLinks, Error> {
let client = http_client::client()?;
let wellknown_url = wellknown_nodeinfo_url(host);
let mut wellknown_response = client.get_async(&wellknown_url).await?;
if !wellknown_response.status().is_success() {
tracing::debug!("{:#?}", wellknown_response.body());
return Err(Error::BadStatus(format!(
"{} returned {}",
wellknown_url,
wellknown_response.status()
)));
}
Ok(serde_json::from_str(&wellknown_response.text().await?)?)
}
fn check_nodeinfo_link(links: NodeinfoLinks) -> Result<String, Error> {
for link in links.links {
if link.rel == "http://nodeinfo.diaspora.software/ns/schema/2.1"
|| link.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0"
{
return Ok(link.href);
}
}
Err(Error::MissingNodeinfo)
}
async fn fetch_nodeinfo_impl(nodeinfo_link: &str) -> Result<Nodeinfo20, Error> {
let client = http_client::client()?;
let mut response = client.get_async(nodeinfo_link).await?;
if !response.status().is_success() {
tracing::debug!("{:#?}", response.body());
return Err(Error::BadStatus(format!(
"{} returned {}",
nodeinfo_link,
response.status()
)));
}
Ok(serde_json::from_str(&response.text().await?)?)
}
// for napi export
type Nodeinfo = Nodeinfo20;
#[crate::export]
pub async fn fetch_nodeinfo(host: &str) -> Result<Nodeinfo, Error> {
tracing::info!("fetching from {}", host);
let links = fetch_nodeinfo_links(host).await?;
let nodeinfo_link = check_nodeinfo_link(links)?;
fetch_nodeinfo_impl(&nodeinfo_link).await
}
#[cfg(test)]
mod unit_test {
use super::{check_nodeinfo_link, fetch_nodeinfo, NodeinfoLink, NodeinfoLinks};
use pretty_assertions::assert_eq;
#[test]
fn test_check_nodeinfo_link() {
let links_1 = NodeinfoLinks {
links: vec![
NodeinfoLink {
rel: "https://example.com/incorrect/schema/2.0".to_string(),
href: "https://example.com/dummy".to_string(),
},
NodeinfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href: "https://example.com/real".to_string(),
},
],
};
assert_eq!(
check_nodeinfo_link(links_1).unwrap(),
"https://example.com/real"
);
let links_2 = NodeinfoLinks {
links: vec![
NodeinfoLink {
rel: "https://example.com/incorrect/schema/2.0".to_string(),
href: "https://example.com/dummy".to_string(),
},
NodeinfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.1".to_string(),
href: "https://example.com/real".to_string(),
},
],
};
assert_eq!(
check_nodeinfo_link(links_2).unwrap(),
"https://example.com/real"
);
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(),
},
NodeinfoLink {
rel: "https://example.com/incorrect/schema/2.1".to_string(),
href: "https://example.com/dummy/2.1".to_string(),
},
],
};
check_nodeinfo_link(links_3).expect_err("No nodeinfo");
}
#[tokio::test]
async fn test_fetch_nodeinfo() {
assert_eq!(
fetch_nodeinfo("info.firefish.dev")
.await
.unwrap()
.software
.name,
"firefish"
);
}
}

View File

@ -0,0 +1,142 @@
use crate::config::CONFIG;
use crate::database::cache;
use crate::database::db_conn;
use crate::misc::meta::fetch_meta;
use crate::model::entity::{note, user};
use crate::service::nodeinfo::schema::*;
use sea_orm::{ColumnTrait, DbErr, EntityTrait, PaginatorTrait, QueryFilter};
use serde_json::json;
use std::collections::HashMap;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Database error: {0}")]
DbErr(#[from] DbErr),
#[error("Cache error: {0}")]
CacheErr(#[from] cache::Error),
#[error("Failed to serialize nodeinfo to JSON: {0}")]
JsonErr(#[from] serde_json::Error),
}
async fn statistics() -> Result<(u64, u64, u64, u64), DbErr> {
let db = db_conn().await?;
let now = chrono::Local::now().naive_local();
const MONTH: chrono::TimeDelta = chrono::Duration::seconds(2592000000);
const HALF_YEAR: chrono::TimeDelta = chrono::Duration::seconds(15552000000);
let local_users = user::Entity::find()
.filter(user::Column::Host.is_null())
.count(db);
let local_active_halfyear = user::Entity::find()
.filter(user::Column::Host.is_null())
.filter(user::Column::LastActiveDate.gt(now - HALF_YEAR))
.count(db);
let local_active_month = user::Entity::find()
.filter(user::Column::Host.is_null())
.filter(user::Column::LastActiveDate.gt(now - MONTH))
.count(db);
let local_posts = note::Entity::find()
.filter(note::Column::UserHost.is_null())
.count(db);
tokio::try_join!(
local_users,
local_active_halfyear,
local_active_month,
local_posts
)
}
async fn generate_nodeinfo_2_1() -> Result<Nodeinfo21, Error> {
let (local_users, local_active_halfyear, local_active_month, local_posts) =
statistics().await?;
let meta = fetch_meta(true).await?;
let metadata = HashMap::from([
(
"nodeName".to_string(),
json!(meta.name.unwrap_or(CONFIG.host.clone())),
),
("nodeDescription".to_string(), json!(meta.description)),
("repositoryUrl".to_string(), json!(meta.repository_url)),
(
"enableLocalTimeline".to_string(),
json!(!meta.disable_local_timeline),
),
(
"enableRecommendedTimeline".to_string(),
json!(!meta.disable_recommended_timeline),
),
(
"enableGlobalTimeline".to_string(),
json!(!meta.disable_global_timeline),
),
(
"enableGuestTimeline".to_string(),
json!(meta.enable_guest_timeline),
),
(
"maintainer".to_string(),
json!({"name":meta.maintainer_name,"email":meta.maintainer_email}),
),
("proxyAccountName".to_string(), json!(meta.proxy_account_id)),
(
"themeColor".to_string(),
json!(meta.theme_color.unwrap_or("#31748f".to_string())),
),
]);
Ok(Nodeinfo21 {
version: "2.1".to_string(),
software: Software21 {
name: "firefish".to_string(),
version: CONFIG.version.clone(),
repository: Some(meta.repository_url),
homepage: Some("https://firefish.dev/firefish/firefish".to_string()),
},
protocols: vec![Protocol::Activitypub],
services: Services {
inbound: vec![],
outbound: vec![Outbound::Atom1, Outbound::Rss2],
},
open_registrations: !meta.disable_registration,
usage: Usage {
users: Users {
total: Some(local_users as u32),
active_halfyear: Some(local_active_halfyear as u32),
active_month: Some(local_active_month as u32),
},
local_posts: Some(local_posts as u32),
local_comments: None,
},
metadata,
})
}
pub async fn nodeinfo_2_1() -> Result<Nodeinfo21, Error> {
const NODEINFO_2_1_CACHE_KEY: &str = "nodeinfo_2_1";
let cached = cache::get::<Nodeinfo21>(NODEINFO_2_1_CACHE_KEY)?;
if let Some(nodeinfo) = cached {
Ok(nodeinfo)
} else {
let nodeinfo = generate_nodeinfo_2_1().await?;
cache::set(NODEINFO_2_1_CACHE_KEY, &nodeinfo, 60 * 60)?;
Ok(nodeinfo)
}
}
pub async fn nodeinfo_2_0() -> Result<Nodeinfo20, Error> {
Ok(nodeinfo_2_1().await?.into())
}
#[crate::export(js_name = "nodeinfo_2_1")]
pub async fn nodeinfo_2_1_as_json() -> Result<serde_json::Value, Error> {
Ok(serde_json::to_value(nodeinfo_2_1().await?)?)
}
#[crate::export(js_name = "nodeinfo_2_0")]
pub async fn nodeinfo_2_0_as_json() -> Result<serde_json::Value, Error> {
Ok(serde_json::to_value(nodeinfo_2_0().await?)?)
}

View File

@ -0,0 +1,3 @@
pub mod fetch;
pub mod generate;
pub mod schema;

View File

@ -0,0 +1,263 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// TODO: I want to use these macros but they don't work with rmp_serde
// - #[serde(skip_serializing_if = "Option::is_none")] (https://github.com/3Hren/msgpack-rust/issues/86)
// - #[serde(tag = "version", rename = "2.1")] (https://github.com/3Hren/msgpack-rust/issues/318)
/// NodeInfo schema version 2.1. https://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.1
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Nodeinfo21 {
/// The schema version, must be 2.1.
pub version: String,
/// Metadata about server software in use.
pub software: Software21,
/// The protocols supported on this server.
pub protocols: Vec<Protocol>,
/// The third party sites this server can connect to via their application API.
pub services: Services,
/// Whether this server allows open self-registration.
pub open_registrations: bool,
/// Usage statistics for this server.
pub usage: Usage,
/// Free form key value pairs for software specific values. Clients should not rely on any specific key present.
pub metadata: HashMap<String, serde_json::Value>,
}
/// NodeInfo schema version 2.0. https://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[crate::export(object, js_name = "Nodeinfo")]
pub struct Nodeinfo20 {
/// The schema version, must be 2.0.
pub version: String,
/// Metadata about server software in use.
pub software: Software20,
/// The protocols supported on this server.
pub protocols: Vec<Protocol>,
/// The third party sites this server can connect to via their application API.
pub services: Services,
/// Whether this server allows open self-registration.
pub open_registrations: bool,
/// Usage statistics for this server.
pub usage: Usage,
/// Free form key value pairs for software specific values. Clients should not rely on any specific key present.
pub metadata: HashMap<String, serde_json::Value>,
}
/// Metadata about server software in use (version 2.1).
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Software21 {
/// The canonical name of this server software.
pub name: String,
/// The version of this server software.
pub version: String,
/// The url of the source code repository of this server software.
pub repository: Option<String>,
/// The url of the homepage of this server software.
pub homepage: Option<String>,
}
/// Metadata about server software in use (version 2.0).
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[crate::export(object)]
pub struct Software20 {
/// The canonical name of this server software.
pub name: String,
/// The version of this server software.
pub version: String,
}
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "lowercase")]
#[crate::export(string_enum = "lowercase")]
pub enum Protocol {
Activitypub,
Buddycloud,
Dfrn,
Diaspora,
Libertree,
Ostatus,
Pumpio,
Tent,
Xmpp,
Zot,
}
/// The third party sites this server can connect to via their application API.
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[crate::export(object)]
pub struct Services {
/// The third party sites this server can retrieve messages from for combined display with regular traffic.
pub inbound: Vec<Inbound>,
/// The third party sites this server can publish messages to on the behalf of a user.
pub outbound: Vec<Outbound>,
}
/// The third party sites this server can retrieve messages from for combined display with regular traffic.
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "lowercase")]
#[crate::export(string_enum = "lowercase")]
pub enum Inbound {
#[serde(rename = "atom1.0")]
Atom1,
Gnusocial,
Imap,
Pnut,
#[serde(rename = "pop3")]
Pop3,
Pumpio,
#[serde(rename = "rss2.0")]
Rss2,
Twitter,
}
/// The third party sites this server can publish messages to on the behalf of a user.
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "lowercase")]
#[crate::export(string_enum = "lowercase")]
pub enum Outbound {
#[serde(rename = "atom1.0")]
Atom1,
Blogger,
Buddycloud,
Diaspora,
Dreamwidth,
Drupal,
Facebook,
Friendica,
Gnusocial,
Google,
Insanejournal,
Libertree,
Linkedin,
Livejournal,
Mediagoblin,
Myspace,
Pinterest,
Pnut,
Posterous,
Pumpio,
Redmatrix,
#[serde(rename = "rss2.0")]
Rss2,
Smtp,
Tent,
Tumblr,
Twitter,
Wordpress,
Xmpp,
}
/// Usage statistics for this server.
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[crate::export(object)]
pub struct Usage {
pub users: Users,
pub local_posts: Option<u32>,
pub local_comments: Option<u32>,
}
/// statistics about the users of this server.
#[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[crate::export(object)]
pub struct Users {
pub total: Option<u32>,
pub active_halfyear: Option<u32>,
pub active_month: Option<u32>,
}
impl From<Software21> for Software20 {
fn from(software: Software21) -> Self {
Self {
name: software.name,
version: software.version,
}
}
}
impl From<Nodeinfo21> for Nodeinfo20 {
fn from(nodeinfo: Nodeinfo21) -> Self {
Self {
version: "2.0".to_string(),
software: nodeinfo.software.into(),
protocols: nodeinfo.protocols,
services: nodeinfo.services,
open_registrations: nodeinfo.open_registrations,
usage: nodeinfo.usage,
metadata: nodeinfo.metadata,
}
}
}
#[cfg(test)]
mod unit_test {
use super::{Nodeinfo20, Nodeinfo21};
use pretty_assertions::assert_eq;
#[test]
fn parse_nodeinfo_2_0() {
let json_str_1 = r#"{"version":"2.0","software":{"name":"mastodon","version":"4.3.0-nightly.2024-04-30"},"protocols":["activitypub"],"services":{"outbound":[],"inbound":[]},"usage":{"users":{"total":1935016,"activeMonth":238223,"activeHalfyear":618795},"localPosts":90175135},"openRegistrations":true,"metadata":{"nodeName":"Mastodon","nodeDescription":"The original server operated by the Mastodon gGmbH non-profit"}}"#;
let parsed_1: Nodeinfo20 = serde_json::from_str(json_str_1).unwrap();
let serialized_1 = serde_json::to_string(&parsed_1).unwrap();
let reparsed_1: Nodeinfo20 = serde_json::from_str(&serialized_1).unwrap();
assert_eq!(parsed_1, reparsed_1);
assert_eq!(parsed_1.software.name, "mastodon");
assert_eq!(parsed_1.software.version, "4.3.0-nightly.2024-04-30");
let json_str_2 = r#"{"version":"2.0","software":{"name":"peertube","version":"5.0.0"},"protocols":["activitypub"],"services":{"inbound":[],"outbound":["atom1.0","rss2.0"]},"openRegistrations":false,"usage":{"users":{"total":5,"activeMonth":0,"activeHalfyear":2},"localPosts":1018,"localComments":1},"metadata":{"taxonomy":{"postsName":"Videos"},"nodeName":"Blender Video","nodeDescription":"Blender Foundation PeerTube instance.","nodeConfig":{"search":{"remoteUri":{"users":true,"anonymous":false}},"plugin":{"registered":[]},"theme":{"registered":[],"default":"default"},"email":{"enabled":false},"contactForm":{"enabled":true},"transcoding":{"hls":{"enabled":true},"webtorrent":{"enabled":true},"enabledResolutions":[1080]},"live":{"enabled":false,"transcoding":{"enabled":true,"enabledResolutions":[]}},"import":{"videos":{"http":{"enabled":true},"torrent":{"enabled":false}}},"autoBlacklist":{"videos":{"ofUsers":{"enabled":false}}},"avatar":{"file":{"size":{"max":4194304},"extensions":[".png",".jpeg",".jpg",".gif",".webp"]}},"video":{"image":{"extensions":[".png",".jpg",".jpeg",".webp"],"size":{"max":4194304}},"file":{"extensions":[".webm",".ogv",".ogg",".mp4",".mkv",".mov",".qt",".mqv",".m4v",".flv",".f4v",".wmv",".avi",".3gp",".3gpp",".3g2",".3gpp2",".nut",".mts",".m2ts",".mpv",".m2v",".m1v",".mpg",".mpe",".mpeg",".vob",".mxf",".mp3",".wma",".wav",".flac",".aac",".m4a",".ac3"]}},"videoCaption":{"file":{"size":{"max":20971520},"extensions":[".vtt",".srt"]}},"user":{"videoQuota":5368709120,"videoQuotaDaily":-1},"trending":{"videos":{"intervalDays":7}},"tracker":{"enabled":true}}}}"#;
let parsed_2: Nodeinfo20 = serde_json::from_str(json_str_2).unwrap();
let serialized_2 = serde_json::to_string(&parsed_2).unwrap();
let reparsed_2: Nodeinfo20 = serde_json::from_str(&serialized_2).unwrap();
assert_eq!(parsed_2, reparsed_2);
assert_eq!(parsed_2.software.name, "peertube");
assert_eq!(parsed_2.software.version, "5.0.0");
let json_str_3 = r#"{"metadata":{"nodeName":"pixelfed","software":{"homepage":"https://pixelfed.org","repo":"https://github.com/pixelfed/pixelfed"},"config":{"features":{"timelines":{"local":true,"network":true},"mobile_apis":true,"stories":true,"video":true,"import":{"instagram":false,"mastodon":false,"pixelfed":false},"label":{"covid":{"enabled":false,"org":"visit the WHO website","url":"https://www.who.int/emergencies/diseases/novel-coronavirus-2019/advice-for-public"}},"hls":{"enabled":false}}}},"protocols":["activitypub"],"services":{"inbound":[],"outbound":[]},"software":{"name":"pixelfed","version":"0.12.0"},"usage":{"localPosts":24059868,"localComments":0,"users":{"total":112832,"activeHalfyear":24366,"activeMonth":8921}},"version":"2.0","openRegistrations":true}"#;
let parsed_3: Nodeinfo20 = serde_json::from_str(json_str_3).unwrap();
let serialized_3 = serde_json::to_string(&parsed_3).unwrap();
let reparsed_3: Nodeinfo20 = serde_json::from_str(&serialized_3).unwrap();
assert_eq!(parsed_3, reparsed_3);
assert_eq!(parsed_3.software.name, "pixelfed");
assert_eq!(parsed_3.software.version, "0.12.0");
}
#[test]
fn parse_nodeinfo_2_1() {
let json_str_1 = r##"{"version":"2.1","software":{"name":"catodon","version":"24.04-dev.2","repository":"https://codeberg.org/catodon/catodon","homepage":"https://codeberg.org/catodon/catodon"},"protocols":["activitypub"],"services":{"inbound":[],"outbound":["atom1.0","rss2.0"]},"openRegistrations":true,"usage":{"users":{"total":294,"activeHalfyear":292,"activeMonth":139},"localPosts":22616,"localComments":0},"metadata":{"nodeName":"Catodon Social","nodeDescription":"🌎 Home of Catodon, a new platform for fedi communities, initially based on Iceshrimp/Firefish/Misskey. Be aware that our first release is not out yet, so things are still experimental.","maintainer":{"name":"admin","email":"redacted@example.com"},"langs":[],"tosUrl":"https://example.com/redacted","repositoryUrl":"https://codeberg.org/catodon/catodon","feedbackUrl":"https://codeberg.org/catodon/catodon/issues","disableRegistration":false,"disableLocalTimeline":false,"disableRecommendedTimeline":true,"disableGlobalTimeline":false,"emailRequiredForSignup":true,"postEditing":true,"postImports":false,"enableHcaptcha":true,"enableRecaptcha":false,"maxNoteTextLength":8000,"maxCaptionTextLength":1500,"enableGithubIntegration":false,"enableDiscordIntegration":false,"enableEmail":true,"themeColor":"#31748f"}}"##;
let parsed_1: Nodeinfo21 = serde_json::from_str(json_str_1).unwrap();
let serialized_1 = serde_json::to_string(&parsed_1).unwrap();
let reparsed_1: Nodeinfo21 = serde_json::from_str(&serialized_1).unwrap();
assert_eq!(parsed_1, reparsed_1);
assert_eq!(parsed_1.software.name, "catodon");
assert_eq!(parsed_1.software.version, "24.04-dev.2");
let json_str_2 = r#"{"version":"2.1","software":{"name":"meisskey","version":"10.102.699-m544","repository":"https://github.com/mei23/misskey"},"protocols":["activitypub"],"services":{"inbound":[],"outbound":["atom1.0","rss2.0"]},"openRegistrations":true,"usage":{"users":{"total":1123,"activeHalfyear":305,"activeMonth":89},"localPosts":268739,"localComments":0},"metadata":{"nodeName":"meisskey.one","nodeDescription":"ローカルタイムラインのないインスタンスなのだわ\n\n\n[通報・報告 (Report)](https://example.com/redacted)","name":"meisskey.one","description":"ローカルタイムラインのないインスタンスなのだわ\n\n\n[通報・報告 (Report)](https://example.com/redacted)","maintainer":{"name":"redacted","email":"redacted"},"langs":[],"announcements":[{"title":"問題・要望など","text":"問題・要望などは <a href=\"https://example.com/redacted\">#meisskeyone要望</a> で投稿してなのだわ"}],"relayActor":"https://example.com/redacted","relays":[],"disableRegistration":false,"disableLocalTimeline":true,"enableRecaptcha":true,"maxNoteTextLength":5000,"enableTwitterIntegration":false,"enableGithubIntegration":false,"enableDiscordIntegration":false,"enableServiceWorker":true,"proxyAccountName":"ghost"}}"#;
let parsed_2: Nodeinfo21 = serde_json::from_str(json_str_2).unwrap();
let serialized_2 = serde_json::to_string(&parsed_2).unwrap();
let reparsed_2: Nodeinfo21 = serde_json::from_str(&serialized_2).unwrap();
assert_eq!(parsed_2, reparsed_2);
assert_eq!(parsed_2.software.name, "meisskey");
assert_eq!(parsed_2.software.version, "10.102.699-m544");
let json_str_3 = r##"{"metadata":{"enableGlobalTimeline":true,"enableGuestTimeline":false,"enableLocalTimeline":true,"enableRecommendedTimeline":false,"maintainer":{"name":"Firefish dev team"},"nodeDescription":"","nodeName":"Firefish","repositoryUrl":"https://firefish.dev/firefish/firefish","themeColor":"#F25A85"},"openRegistrations":false,"protocols":["activitypub"],"services":{"inbound":[],"outbound":["atom1.0","rss2.0"]},"software":{"homepage":"https://firefish.dev/firefish/firefish","name":"firefish","repository":"https://firefish.dev/firefish/firefish","version":"20240504"},"usage":{"localPosts":23857,"users":{"activeHalfyear":7,"activeMonth":7,"total":9}},"version":"2.1"}"##;
let parsed_3: Nodeinfo20 = serde_json::from_str(json_str_3).unwrap();
let serialized_3 = serde_json::to_string(&parsed_3).unwrap();
let reparsed_3: Nodeinfo20 = serde_json::from_str(&serialized_3).unwrap();
assert_eq!(parsed_3, reparsed_3);
assert_eq!(parsed_3.software.name, "firefish");
assert_eq!(parsed_3.software.version, "20240504");
}
}

View File

@ -1,24 +1,34 @@
use crate::config::CONFIG;
use isahc::{config::*, HttpClient};
use once_cell::sync::OnceCell;
use reqwest::{Client, Error, NoProxy, Proxy};
use std::time::Duration;
static CLIENT: OnceCell<Client> = OnceCell::new();
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Isahc error: {0}")]
IsahcErr(#[from] isahc::Error),
#[error("Url parse error: {0}")]
UrlParseErr(#[from] isahc::http::uri::InvalidUri),
}
pub fn http_client() -> Result<Client, Error> {
static CLIENT: OnceCell<HttpClient> = OnceCell::new();
pub fn client() -> Result<HttpClient, Error> {
CLIENT
.get_or_try_init(|| {
let mut builder = Client::builder().timeout(Duration::from_secs(5));
let mut builder = HttpClient::builder()
.timeout(Duration::from_secs(10))
.default_header("user-agent", &CONFIG.user_agent)
.dns_cache(DnsCache::Timeout(Duration::from_secs(60 * 60)));
if let Some(proxy_url) = &CONFIG.proxy {
let mut proxy = Proxy::all(proxy_url)?;
builder = builder.proxy(Some(proxy_url.parse()?));
if let Some(proxy_bypass_hosts) = &CONFIG.proxy_bypass_hosts {
proxy = proxy.no_proxy(NoProxy::from_string(&proxy_bypass_hosts.join(",")));
builder = builder.proxy_blacklist(proxy_bypass_hosts);
}
builder = builder.proxy(proxy);
}
builder.build()
Ok(builder.build()?)
})
.cloned()
}

View File

@ -1,5 +1,3 @@
pub use http_client::http_client;
pub mod http_client;
pub mod id;
pub mod random;

View File

@ -19,6 +19,13 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
return appendChildren(childNodes, background).join("").trim();
}
/**
* We only exclude text containing asterisks, since the other marks can almost be considered intentionally used.
*/
function escapeAmbiguousMfmMarks(text: string) {
return text.includes("*") ? `<plain>${text}</plain>` : text;
}
/**
* Get only the text, ignoring all formatting inside
* @param node
@ -62,7 +69,7 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
background = "",
): (string | string[])[] {
if (treeAdapter.isTextNode(node)) {
return [node.value];
return [escapeAmbiguousMfmMarks(node.value)];
}
// Skip comment or document type node

View File

@ -0,0 +1,65 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class DropUnusedIndexes1714643926317 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_01f4581f114e0ebd2bbb876f0b"`);
await queryRunner.query(`DROP INDEX "IDX_0610ebcfcfb4a18441a9bcdab2"`);
await queryRunner.query(`DROP INDEX "IDX_25dfc71b0369b003a4cd434d0b"`);
await queryRunner.query(`DROP INDEX "IDX_2710a55f826ee236ea1a62698f"`);
await queryRunner.query(`DROP INDEX "IDX_4c02d38a976c3ae132228c6fce"`);
await queryRunner.query(`DROP INDEX "IDX_51c063b6a133a9cb87145450f5"`);
await queryRunner.query(`DROP INDEX "IDX_54ebcb6d27222913b908d56fd8"`);
await queryRunner.query(`DROP INDEX "IDX_7fa20a12319c7f6dc3aed98c0a"`);
await queryRunner.query(`DROP INDEX "IDX_88937d94d7443d9a99a76fa5c0"`);
await queryRunner.query(`DROP INDEX "IDX_b11a5e627c41d4dc3170f1d370"`);
await queryRunner.query(`DROP INDEX "IDX_c8dfad3b72196dd1d6b5db168a"`);
await queryRunner.query(`DROP INDEX "IDX_d57f9030cd3af7f63ffb1c267c"`);
await queryRunner.query(`DROP INDEX "IDX_e5848eac4940934e23dbc17581"`);
await queryRunner.query(`DROP INDEX "IDX_fa99d777623947a5b05f394cae"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE INDEX "IDX_01f4581f114e0ebd2bbb876f0b" ON "note_reaction" ("createdAt")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_0610ebcfcfb4a18441a9bcdab2" ON "poll" ("userId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_25dfc71b0369b003a4cd434d0b" ON "note" ("attachedFileTypes")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_2710a55f826ee236ea1a62698f" ON "hashtag" ("mentionedUsersCount")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_4c02d38a976c3ae132228c6fce" ON "hashtag" ("mentionedRemoteUsersCount")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_51c063b6a133a9cb87145450f5" ON "note" ("fileIds")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_54ebcb6d27222913b908d56fd8" ON "note" ("mentions")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7fa20a12319c7f6dc3aed98c0a" ON "poll" ("userHost")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_88937d94d7443d9a99a76fa5c0" ON "note" ("tags")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_b11a5e627c41d4dc3170f1d370" ON "notification" ("createdAt")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_c8dfad3b72196dd1d6b5db168a" ON "drive_file" ("createdAt")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_d57f9030cd3af7f63ffb1c267c" ON "hashtag" ("attachedUsersCount")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_e5848eac4940934e23dbc17581" ON "drive_file" ("uri")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_fa99d777623947a5b05f394cae" ON "user" ("tags")`,
);
}
}

View File

@ -6,11 +6,10 @@ export class CreateScheduledNoteCreation1714728200194
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "scheduled_note_creation" (
"id" character varying(32) NOT NULL,
"id" character varying(32) NOT NULL PRIMARY KEY,
"noteId" character varying(32) NOT NULL,
"userId" character varying(32) NOT NULL,
"scheduledAt" TIMESTAMP WITHOUT TIME ZONE NOT NULL,
CONSTRAINT "PK_id_ScheduledNoteCreation" PRIMARY KEY ("id")
"scheduledAt" TIMESTAMP WITHOUT TIME ZONE NOT NULL
)`,
);
await queryRunner.query(`
@ -24,31 +23,19 @@ export class CreateScheduledNoteCreation1714728200194
`);
await queryRunner.query(`
ALTER TABLE "scheduled_note_creation"
ADD CONSTRAINT "FK_noteId_ScheduledNoteCreation"
FOREIGN KEY ("noteId")
REFERENCES "note"("id")
ADD FOREIGN KEY ("noteId") REFERENCES "note"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION
`);
await queryRunner.query(`
ALTER TABLE "scheduled_note_creation"
ADD CONSTRAINT "FK_userId_ScheduledNoteCreation"
FOREIGN KEY ("userId")
REFERENCES "user"("id")
ADD FOREIGN KEY ("userId") REFERENCES "user"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "scheduled_note_creation" DROP CONSTRAINT "FK_noteId_ScheduledNoteCreation"
`);
await queryRunner.query(`
ALTER TABLE "scheduled_note_creation" DROP CONSTRAINT "FK_userId_ScheduledNoteCreation"
`);
await queryRunner.query(`
DROP TABLE "scheduled_note_creation"
`);
await queryRunner.query(`DROP TABLE "scheduled_note_creation"`);
}
}

View File

@ -0,0 +1,13 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class AddUserProfileLanguage1714888400293 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_profile" ADD COLUMN "lang" character varying(32)`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "lang"`);
}
}

View File

@ -7,10 +7,10 @@ import chalk from "chalk";
import Logger from "@/services/logger.js";
import IPCIDR from "ip-cidr";
import PrivateIp from "private-ip";
import { isValidUrl } from "./is-valid-url.js";
import { isSafeUrl } from "backend-rs";
export async function downloadUrl(url: string, path: string): Promise<void> {
if (!isValidUrl(url)) {
if (!isSafeUrl(url)) {
throw new StatusError("Invalid URL", 400);
}
@ -43,8 +43,8 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
limit: 0,
},
})
.on("redirect", (res: Got.Response, opts: Got.NormalizedOptions) => {
if (!isValidUrl(opts.url)) {
.on("redirect", (_res: Got.Response, opts: Got.NormalizedOptions) => {
if (!isSafeUrl(opts.url)) {
downloadLogger.warn(`Invalid URL: ${opts.url}`);
req.destroy();
}

View File

@ -5,7 +5,7 @@ import CacheableLookup from "cacheable-lookup";
import fetch, { type RequestRedirect } from "node-fetch";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
import { config } from "@/config.js";
import { isValidUrl } from "./is-valid-url.js";
import { isSafeUrl } from "backend-rs";
export async function getJson(
url: string,
@ -60,7 +60,7 @@ export async function getResponse(args: {
size?: number;
redirect?: RequestRedirect;
}) {
if (!isValidUrl(args.url)) {
if (!isSafeUrl(args.url)) {
throw new StatusError("Invalid URL", 400);
}
@ -83,7 +83,7 @@ export async function getResponse(args: {
});
if (args.redirect === "manual" && [301, 302, 307, 308].includes(res.status)) {
if (!isValidUrl(res.url)) {
if (!isSafeUrl(res.url)) {
throw new StatusError("Invalid URL", 400);
}
return res;

View File

@ -1,20 +0,0 @@
export function isValidUrl(url: string | URL | undefined): boolean {
if (process.env.NODE_ENV !== "production") return true;
try {
if (url == null) return false;
const u = typeof url === "string" ? new URL(url) : url;
if (!u.protocol.match(/^https?:$/) || u.hostname === "unix") {
return false;
}
if (u.port !== "" && !["80", "443"].includes(u.port)) {
return false;
}
return true;
} catch {
return false;
}
}

View File

@ -23,7 +23,6 @@ export class DriveFile {
@PrimaryColumn(id())
public id: string;
@Index()
@Column("timestamp without time zone", {
comment: "The created date of the DriveFile.",
})
@ -147,7 +146,6 @@ export class DriveFile {
})
public webpublicAccessKey: string | null;
@Index()
@Column("varchar", {
length: 512,
nullable: true,

View File

@ -19,7 +19,6 @@ export class Hashtag {
})
public mentionedUserIds: User["id"][];
@Index()
@Column("integer", {
default: 0,
})
@ -43,7 +42,6 @@ export class Hashtag {
})
public mentionedRemoteUserIds: User["id"][];
@Index()
@Column("integer", {
default: 0,
})
@ -55,7 +53,6 @@ export class Hashtag {
})
public attachedUserIds: User["id"][];
@Index()
@Column("integer", {
default: 0,
})

View File

@ -17,7 +17,6 @@ export class NoteReaction {
@PrimaryColumn(id())
public id: string;
@Index()
@Column("timestamp without time zone", {
comment: "The created date of the NoteReaction.",
})

View File

@ -139,7 +139,6 @@ export class Note {
// FIXME: file id is not removed from this array even if the file is deleted
// TODO: drop this column and use note_files
@Index()
@Column({
...id(),
array: true,
@ -147,7 +146,6 @@ export class Note {
})
public fileIds: DriveFile["id"][];
@Index()
@Column("varchar", {
length: 256,
array: true,
@ -163,7 +161,6 @@ export class Note {
})
public visibleUserIds: User["id"][];
@Index()
@Column({
...id(),
array: true,
@ -184,7 +181,6 @@ export class Note {
})
public emojis: string[];
@Index()
@Column("varchar", {
length: 128,
array: true,

View File

@ -20,7 +20,6 @@ export class Notification {
@PrimaryColumn(id())
public id: string;
@Index()
@Column("timestamp without time zone", {
comment: "The created date of the Notification.",
})

View File

@ -44,14 +44,12 @@ export class Poll {
})
public noteVisibility: (typeof noteVisibilities)[number];
@Index()
@Column({
...id(),
comment: "[Denormalized]",
})
public userId: User["id"];
@Index()
@Column("varchar", {
length: 512,
nullable: true,

View File

@ -5,6 +5,7 @@ import {
ManyToOne,
PrimaryColumn,
Index,
type Relation,
} from "typeorm";
import { Note } from "./note.js";
import { id } from "../id.js";
@ -34,11 +35,12 @@ export class ScheduledNoteCreation {
onDelete: "CASCADE",
})
@JoinColumn()
public note: Note;
public note: Relation<Note>;
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
@JoinColumn()
public user: User;
public user: Relation<User>;
//#endregion
}

View File

@ -50,6 +50,12 @@ export class UserProfile {
verified?: boolean;
}[];
@Column("varchar", {
length: 32,
nullable: true,
})
public lang: string | null;
@Column("varchar", {
length: 512,
nullable: true,

View File

@ -116,7 +116,6 @@ export class User {
})
public bannerId: DriveFile["id"] | null;
@Index()
@Column("varchar", {
length: 128,
array: true,

View File

@ -512,6 +512,7 @@ export const UserRepository = db.getRepository(User).extend({
description: profile!.description,
location: profile!.location,
birthday: profile!.birthday,
lang: profile!.lang,
fields: profile!.fields,
followersCount: followersCount ?? null,
followingCount: followingCount ?? null,

View File

@ -204,6 +204,12 @@ export const packedUserDetailedNotMeOnlySchema = {
optional: false,
example: "2018-03-12",
},
lang: {
type: "string",
nullable: true,
optional: false,
example: "ja-JP",
},
fields: {
type: "array",
nullable: false,

View File

@ -1,7 +1,7 @@
// https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0
import type { Primitive } from "type-fest";
type NullToUndefined<T> = T extends null
export type NullToUndefined<T> = T extends null
? undefined
: T extends Primitive | Function | Date | RegExp
? T
@ -15,7 +15,7 @@ type NullToUndefined<T> = T extends null
? { [K in keyof T]: NullToUndefined<T[K]> }
: unknown;
type UndefinedToNull<T> = T extends undefined
export type UndefinedToNull<T> = T extends undefined
? null
: T extends Primitive | Function | Date | RegExp
? T
@ -47,6 +47,16 @@ function _nullToUndefined<T>(obj: T): NullToUndefined<T> {
return obj as any;
}
/**
* Recursively converts all null values to undefined.
*
* @param obj object to convert
* @returns a copy of the object with all its null values converted to undefined
*/
export function fromRustObject<T>(obj: T) {
return _nullToUndefined(structuredClone(obj));
}
function _undefinedToNull<T>(obj: T): UndefinedToNull<T> {
if (obj === undefined) {
return null as any;
@ -71,6 +81,6 @@ function _undefinedToNull<T>(obj: T): UndefinedToNull<T> {
* @param obj object to convert
* @returns a copy of the object with all its undefined values converted to null
*/
export function undefinedToNull<T>(obj: T) {
export function toRustObject<T>(obj: T) {
return _undefinedToNull(structuredClone(obj));
}

View File

@ -527,7 +527,7 @@ export const WellKnownContext = {
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
movedTo: {
"@id": "https://www.w3.org/ns/activitystreams#movedTo",
"@type": "@id"
"@type": "@id",
},
movedToUri: "as:movedTo",
sensitive: "as:sensitive",

View File

@ -5,8 +5,8 @@ import { StatusError, getResponse } from "@/misc/fetch.js";
import { createSignedPost, createSignedGet } from "./ap-request.js";
import type { Response } from "node-fetch";
import type { IObject } from "./type.js";
import { isValidUrl } from "@/misc/is-valid-url.js";
import { apLogger } from "@/remote/activitypub/logger.js";
import { isSafeUrl } from "backend-rs";
export default async (user: { id: User["id"] }, url: string, object: any) => {
const body = JSON.stringify(object);
@ -44,7 +44,7 @@ export async function apGet(
user?: ILocalUser,
redirects: boolean = true,
): Promise<{ finalUrl: string; content: IObject }> {
if (!isValidUrl(url)) {
if (!isSafeUrl(url)) {
throw new StatusError("Invalid URL", 400);
}

View File

@ -286,7 +286,6 @@ import * as ep___pinnedUsers from "./endpoints/pinned-users.js";
import * as ep___customMotd from "./endpoints/custom-motd.js";
import * as ep___customSplashIcons from "./endpoints/custom-splash-icons.js";
import * as ep___latestVersion from "./endpoints/latest-version.js";
import * as ep___release from "./endpoints/release.js";
import * as ep___promo_read from "./endpoints/promo/read.js";
import * as ep___requestResetPassword from "./endpoints/request-reset-password.js";
import * as ep___resetPassword from "./endpoints/reset-password.js";
@ -635,7 +634,6 @@ const eps = [
["custom-motd", ep___customMotd],
["custom-splash-icons", ep___customSplashIcons],
["latest-version", ep___latestVersion],
["release", ep___release],
["promo/read", ep___promo_read],
["request-reset-password", ep___requestResetPassword],
["reset-password", ep___resetPassword],

View File

@ -87,6 +87,7 @@ export const paramDef = {
description: { ...Users.descriptionSchema, nullable: true },
location: { ...Users.locationSchema, nullable: true },
birthday: { ...Users.birthdaySchema, nullable: true },
lang: { type: "string", nullable: true },
avatarId: { type: "string", format: "misskey:id", nullable: true },
bannerId: { type: "string", format: "misskey:id", nullable: true },
fields: {
@ -154,6 +155,7 @@ export default define(meta, paramDef, async (ps, _user, token) => {
if (ps.name !== undefined) updates.name = ps.name;
if (ps.description !== undefined) profileUpdates.description = ps.description;
if (typeof ps.lang === "string") profileUpdates.lang = ps.lang;
if (ps.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.ffVisibility !== undefined)

View File

@ -1,4 +1,5 @@
import define from "@/server/api/define.js";
import { latestVersion } from "backend-rs";
export const meta = {
tags: ["meta"],
@ -14,14 +15,7 @@ export const paramDef = {
} as const;
export default define(meta, paramDef, async () => {
let latest_version;
await fetch("https://firefish.dev/firefish/firefish/-/raw/main/package.json")
.then((response) => response.json())
.then((data) => {
latest_version = data.version;
});
return {
latest_version,
latest_version: await latestVersion(),
};
});

View File

@ -233,7 +233,7 @@ export default define(meta, paramDef, async (ps, user) => {
// Check blocking
if (renote.userId !== user.id) {
const isBlocked = await Blockings.exist({
const isBlocked = await Blockings.exists({
where: {
blockerId: renote.userId,
blockeeId: user.id,
@ -260,7 +260,7 @@ export default define(meta, paramDef, async (ps, user) => {
// Check blocking
if (reply.userId !== user.id) {
const isBlocked = await Blockings.exist({
const isBlocked = await Blockings.exists({
where: {
blockerId: reply.userId,
blockeeId: user.id,
@ -280,14 +280,13 @@ export default define(meta, paramDef, async (ps, user) => {
if (
ps.poll.expiresAt &&
ps.scheduledAt &&
ps.poll.expiresAt < Number(new Date(ps.scheduledAt))
ps.poll.expiresAt < ps.scheduledAt
) {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
}
} else if (typeof ps.poll.expiredAfter === "number") {
if (ps.scheduledAt) {
ps.poll.expiresAt =
Number(new Date(ps.scheduledAt)) + ps.poll.expiredAfter;
if (ps.scheduledAt != null) {
ps.poll.expiresAt = ps.scheduledAt + ps.poll.expiredAfter;
} else {
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
}
@ -305,7 +304,7 @@ export default define(meta, paramDef, async (ps, user) => {
let delay: number | null = null;
if (ps.scheduledAt) {
delay = Number(ps.scheduledAt) - Number(new Date());
delay = ps.scheduledAt - Date.now();
if (delay < 0) {
delay = null;
}

View File

@ -1,28 +0,0 @@
import define from "@/server/api/define.js";
export const meta = {
tags: ["meta"],
description: "Get release notes from Codeberg",
requireCredential: false,
requireCredentialPrivateMode: false,
} as const;
export const paramDef = {
type: "object",
properties: {},
required: [],
} as const;
export default define(meta, paramDef, async () => {
let release;
await fetch(
"https://firefish.dev/firefish/firefish/-/raw/develop/release.json",
)
.then((response) => response.json())
.then((data) => {
release = data;
});
return release;
});

View File

@ -1,9 +1,7 @@
import Router from "@koa/router";
import { config } from "@/config.js";
import { fetchMeta } from "backend-rs";
import { Users, Notes } from "@/models/index.js";
import { IsNull, MoreThan } from "typeorm";
import { Cache } from "@/misc/cache.js";
import { nodeinfo_2_0, nodeinfo_2_1 } from "backend-rs";
import { fromRustObject } from "@/prelude/undefined-to-null.js";
const router = new Router();
@ -22,101 +20,14 @@ export const links = [
},
];
const nodeinfo2 = async () => {
const now = Date.now();
const [meta, total, activeHalfyear, activeMonth, localPosts] =
await Promise.all([
fetchMeta(false),
Users.count({ where: { host: IsNull() } }),
Users.count({
where: {
host: IsNull(),
lastActiveDate: MoreThan(new Date(now - 15552000000)),
},
}),
Users.count({
where: {
host: IsNull(),
lastActiveDate: MoreThan(new Date(now - 2592000000)),
},
}),
Notes.count({ where: { userHost: IsNull() } }),
]);
const proxyAccount = meta.proxyAccountId
? await Users.pack(meta.proxyAccountId).catch(() => null)
: null;
return {
software: {
name: "firefish",
version: config.version,
repository: meta.repositoryUrl,
homepage: "https://firefish.dev/firefish/firefish",
},
protocols: ["activitypub"],
services: {
inbound: [] as string[],
outbound: ["atom1.0", "rss2.0"],
},
openRegistrations: !meta.disableRegistration,
usage: {
users: { total, activeHalfyear, activeMonth },
localPosts,
localComments: 0,
},
metadata: {
nodeName: meta.name,
nodeDescription: meta.description,
maintainer: {
name: meta.maintainerName,
email: meta.maintainerEmail,
},
langs: meta.langs,
tosUrl: meta.tosUrl,
repositoryUrl: meta.repositoryUrl,
feedbackUrl: meta.feedbackUrl,
disableRegistration: meta.disableRegistration,
disableLocalTimeline: meta.disableLocalTimeline,
disableRecommendedTimeline: meta.disableRecommendedTimeline,
disableGlobalTimeline: meta.disableGlobalTimeline,
emailRequiredForSignup: meta.emailRequiredForSignup,
postEditing: true,
postImports: meta.experimentalFeatures?.postImports || false,
enableHcaptcha: meta.enableHcaptcha,
enableRecaptcha: meta.enableRecaptcha,
maxNoteTextLength: config.maxNoteLength,
maxCaptionTextLength: config.maxCaptionLength,
enableEmail: meta.enableEmail,
enableServiceWorker: meta.enableServiceWorker,
proxyAccountName: proxyAccount ? proxyAccount.username : null,
themeColor: meta.themeColor || "#31748f",
},
};
};
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(
"nodeinfo",
60 * 10,
);
router.get(nodeinfo2_1path, async (ctx) => {
const base = await cache.fetch(null, () => nodeinfo2());
ctx.body = { version: "2.1", ...base };
ctx.set("Cache-Control", "public, max-age=600");
ctx.body = fromRustObject(await nodeinfo_2_1());
ctx.set("Cache-Control", "public, max-age=3600");
});
router.get(nodeinfo2_0path, async (ctx) => {
const base = await cache.fetch(null, () => nodeinfo2());
// @ts-ignore
base.software.repository = undefined;
// @ts-ignore
base.software.homepage = undefined;
ctx.body = { version: "2.0", ...base };
ctx.set("Cache-Control", "public, max-age=600");
ctx.body = fromRustObject(await nodeinfo_2_0());
ctx.set("Cache-Control", "public, max-age=3600");
});
export default router;

View File

@ -10,6 +10,7 @@ import {
import { Instances } from "@/models/index.js";
import { getFetchInstanceMetadataLock } from "@/misc/app-lock.js";
import Logger from "@/services/logger.js";
import { type Nodeinfo, fetchNodeinfo } from "backend-rs";
import { inspect } from "node:util";
const logger = new Logger("metadata", "cyan");
@ -36,7 +37,7 @@ export async function fetchInstanceMetadata(
try {
const [info, dom, manifest] = await Promise.all([
fetchNodeinfo(instance).catch(() => null),
fetchNodeinfo(instance.host).catch(() => null),
fetchDom(instance).catch(() => null),
fetchManifest(instance).catch(() => null),
]);
@ -57,30 +58,26 @@ export async function fetchInstanceMetadata(
if (info) {
updates.softwareName =
info.software?.name
?.toLowerCase()
info.software.name
.toLowerCase()
.substring(0, MAX_LENGTH_INSTANCE.softwareName) || null;
updates.softwareVersion =
info.software?.version?.substring(
info.software.version.substring(
0,
MAX_LENGTH_INSTANCE.softwareVersion,
) || null;
updates.openRegistrations = info.openRegistrations;
updates.maintainerName = info.metadata
? info.metadata.maintainer
? info.metadata.maintainer.name?.substring(
0,
MAX_LENGTH_INSTANCE.maintainerName,
) || null
: null
updates.maintainerName = info.metadata.maintainer
? info.metadata.maintainer.name?.substring(
0,
MAX_LENGTH_INSTANCE.maintainerName,
) || null
: null;
updates.maintainerEmail = info.metadata
? info.metadata.maintainer
? info.metadata.maintainer.email?.substring(
0,
MAX_LENGTH_INSTANCE.maintainerEmail,
) || null
: null
updates.maintainerEmail = info.metadata.maintainer
? info.metadata.maintainer.email?.substring(
0,
MAX_LENGTH_INSTANCE.maintainerEmail,
) || null
: null;
}
@ -115,75 +112,6 @@ export async function fetchInstanceMetadata(
}
}
type NodeInfo = {
openRegistrations?: boolean;
software?: {
name?: string;
version?: string;
};
metadata?: {
name?: string;
nodeName?: string;
nodeDescription?: string;
description?: string;
maintainer?: {
name?: string;
email?: string;
};
};
};
async function fetchNodeinfo(instance: Instance): Promise<NodeInfo> {
logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try {
const wellknown = (await getJson(
`https://${instance.host}/.well-known/nodeinfo`,
).catch((e) => {
if (e.statusCode === 404) {
throw new Error("No nodeinfo provided");
} else {
throw new Error(inspect(e));
}
})) as Record<string, unknown>;
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
throw new Error("No wellknown links");
}
const links = wellknown.links as any[];
const lnik1_0 = links.find(
(link) => link.rel === "http://nodeinfo.diaspora.software/ns/schema/1.0",
);
const lnik2_0 = links.find(
(link) => link.rel === "http://nodeinfo.diaspora.software/ns/schema/2.0",
);
const lnik2_1 = links.find(
(link) => link.rel === "http://nodeinfo.diaspora.software/ns/schema/2.1",
);
const link = lnik2_1 || lnik2_0 || lnik1_0;
if (link == null) {
throw new Error("No nodeinfo link provided");
}
const info = await getJson(link.href).catch((e) => {
throw new Error(inspect(e));
});
logger.info(`Successfuly fetched nodeinfo of ${instance.host}`);
return info as NodeInfo;
} catch (e) {
logger.error(
`Failed to fetch nodeinfo of ${instance.host}:\n${inspect(e)}`,
);
throw e;
}
}
async function fetchDom(instance: Instance): Promise<Window["document"]> {
logger.info(`Fetching HTML of ${instance.host} ...`);
@ -272,7 +200,7 @@ async function fetchIconUrl(
}
async function getThemeColor(
info: NodeInfo | null,
info: Nodeinfo | null,
doc: Window["document"] | null,
manifest: Record<string, any> | null,
): Promise<string | null> {
@ -290,7 +218,7 @@ async function getThemeColor(
}
async function getSiteName(
info: NodeInfo | null,
info: Nodeinfo | null,
doc: Window["document"] | null,
manifest: Record<string, any> | null,
): Promise<string | undefined | null> {
@ -318,7 +246,7 @@ async function getSiteName(
}
async function getDescription(
info: NodeInfo | null,
info: Nodeinfo | null,
doc: Window["document"] | null,
manifest: Record<string, any> | null,
): Promise<string | null> {

View File

@ -66,7 +66,7 @@ import { Mutex } from "redis-semaphore";
import { langmap } from "@/misc/langmap.js";
import Logger from "@/services/logger.js";
import { inspect } from "node:util";
import { undefinedToNull } from "@/prelude/undefined-to-null.js";
import { toRustObject } from "@/prelude/undefined-to-null.js";
const logger = new Logger("create-note");
@ -407,7 +407,7 @@ export default async (
checkHitAntenna(antenna, note, user).then((hit) => {
if (hit) {
// TODO: do this more sanely
addNoteToAntenna(antenna.id, undefinedToNull(note) as Note);
addNoteToAntenna(antenna.id, toRustObject(note));
}
});
}

View File

@ -21,8 +21,6 @@
"@syuilo/aiscript": "0.17.0",
"@types/autosize": "^4.0.3",
"@types/glob": "8.1.0",
"@types/gulp": "4.0.17",
"@types/gulp-rename": "2.0.6",
"@types/insert-text-at-cursor": "^0.3.2",
"@types/katex": "0.16.7",
"@types/matter-js": "0.19.6",

View File

@ -132,6 +132,9 @@ export async function signIn(token: Account["token"], redirect?: string) {
if (_DEV_) console.log("logging as token ", token);
const newAccount = await fetchAccount(token);
localStorage.setItem("account", JSON.stringify(newAccount));
if (newAccount.lang) {
localStorage.setItem("lang", newAccount.lang);
}
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
await addAccount(newAccount.id, token);

View File

@ -130,7 +130,7 @@
v-else-if="item.type === 'parent'"
class="_button item parent"
:class="{ childShowing: childShowingItem === item }"
@mouseenter="showChildren(item, $event)"
@mouseenter.passive="showChildren(item, $event)"
@click.stop="showChildren(item, $event)"
>
<i
@ -318,6 +318,7 @@ function onItemMouseLeave(_item) {
async function showChildren(item: MenuParent, ev: MouseEvent) {
if (props.asDrawer) {
if (ev.type === "mouseenter") return;
os.popupMenu(item.children, (ev.currentTarget ?? ev.target) as HTMLElement);
close();
} else {

View File

@ -71,7 +71,7 @@ import { foldNotifications } from "@/scripts/fold";
import { defaultStore } from "@/store";
const props = defineProps<{
includeTypes?: (typeof notificationTypes)[number][];
includeTypes?: (typeof notificationTypes)[number][] | null;
unreadOnly?: boolean;
}>();

View File

@ -44,6 +44,7 @@ const FIRE_THRESHOLD = defaultStore.state.pullToRefreshThreshold;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 170;
const MAX_PULL_TAN_ANGLE = Math.tan((1 / 6) * Math.PI); // 30°
const pullStarted = ref(false);
const pullEnded = ref(false);
@ -53,6 +54,7 @@ const pullDistance = ref(0);
let disabled = false;
const supportPointerDesktop = false;
let startScreenY: number | null = null;
let startScreenX: number | null = null;
const rootEl = shallowRef<HTMLDivElement>();
let scrollEl: HTMLElement | null = null;
@ -72,11 +74,16 @@ function getScreenY(event) {
if (supportPointerDesktop) return event.screenY;
return event.touches[0].screenY;
}
function getScreenX(event) {
if (supportPointerDesktop) return event.screenX;
return event.touches[0].screenX;
}
function moveStart(event) {
if (!pullStarted.value && !isRefreshing.value && !disabled) {
pullStarted.value = true;
startScreenY = getScreenY(event);
startScreenX = getScreenX(event);
pullDistance.value = 0;
}
}
@ -117,6 +124,7 @@ async function closeContent() {
function moveEnd() {
if (pullStarted.value && !isRefreshing.value) {
startScreenY = null;
startScreenX = null;
if (pullEnded.value) {
pullEnded.value = false;
isRefreshing.value = true;
@ -146,11 +154,17 @@ function moving(event: TouchEvent | PointerEvent) {
moveEnd();
return;
}
if (startScreenY === null) {
startScreenY = getScreenY(event);
}
startScreenX ??= getScreenX(event);
startScreenY ??= getScreenY(event);
const moveScreenY = getScreenY(event);
const moveScreenX = getScreenX(event);
const moveHeight = moveScreenY - startScreenY!;
const moveWidth = moveScreenX - startScreenX!;
if (Math.abs(moveWidth / moveHeight) > MAX_PULL_TAN_ANGLE) {
if (Math.abs(moveWidth) > 30) pullStarted.value = false;
return;
}
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
if (pullDistance.value > 0) {

View File

@ -10,17 +10,6 @@
<MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle>
</div>
<div :class="$style.version"> {{ version }} 🚀</div>
<div v-if="newRelease" :class="$style.releaseNotes">
<Mfm :text="data.notes" />
<div v-if="data.screenshots.length > 0" style="max-width: 500">
<img
v-for="i in data.screenshots"
:key="i"
:src="i"
alt="screenshot"
/>
</div>
</div>
<MkButton
:class="$style.gotIt"
primary
@ -33,28 +22,14 @@
</template>
<script lang="ts" setup>
import { ref, shallowRef } from "vue";
import { shallowRef } from "vue";
import MkModal from "@/components/MkModal.vue";
import MkSparkle from "@/components/MkSparkle.vue";
import MkButton from "@/components/MkButton.vue";
import { version } from "@/config";
import { i18n } from "@/i18n";
import * as os from "@/os";
const modal = shallowRef<InstanceType<typeof MkModal>>();
const newRelease = ref(false);
const data = ref(Object);
os.api("release").then((res) => {
data.value = res;
newRelease.value = version === data.value?.version;
});
console.log(`Version: ${version}`);
console.log(`Data version: ${data.value.version}`);
console.log(newRelease.value);
console.log(data.value);
</script>
<style lang="scss" module>
@ -81,10 +56,4 @@ console.log(data.value);
.gotIt {
margin: 8px 0 0 0;
}
.releaseNotes {
> img {
border-radius: 10px;
}
}
</style>

View File

@ -283,44 +283,44 @@ export default defineComponent({
? "perspective(128px) rotateY"
: "rotate";
const degrees = Number.parseFloat(
token.props.args.deg.toString() ?? "90",
(token.props.args.deg ?? "90").toString(),
);
style = `transform: ${rotate}(${degrees}deg); transform-origin: center center;`;
break;
}
case "position": {
const x = Number.parseFloat(
token.props.args.x.toString() ?? "0",
(token.props.args.x ?? "0").toString(),
);
const y = Number.parseFloat(
token.props.args.y.toString() ?? "0",
(token.props.args.y ?? "0").toString(),
);
style = `transform: translateX(${x}em) translateY(${y}em);`;
break;
}
case "crop": {
const top = Number.parseFloat(
token.props.args.top.toString() ?? "0",
(token.props.args.top ?? "0").toString(),
);
const right = Number.parseFloat(
token.props.args.right.toString() ?? "0",
(token.props.args.right ?? "0").toString(),
);
const bottom = Number.parseFloat(
token.props.args.bottom.toString() ?? "0",
(token.props.args.bottom ?? "0").toString(),
);
const left = Number.parseFloat(
token.props.args.left.toString() ?? "0",
(token.props.args.left ?? "0").toString(),
);
style = `clip-path: inset(${top}% ${right}% ${bottom}% ${left}%);`;
break;
}
case "scale": {
const x = Math.min(
Number.parseFloat(token.props.args.x.toString() ?? "1"),
Number.parseFloat((token.props.args.x ?? "1").toString()),
5,
);
const y = Math.min(
Number.parseFloat(token.props.args.y.toString() ?? "1"),
Number.parseFloat((token.props.args.y ?? "1").toString()),
5,
);
style = `transform: scale(${x}, ${y});`;

View File

@ -27,6 +27,7 @@
>
<swiper-slide>
<XNotifications
:key="'tab1'"
class="notifications"
:include-types="includeTypes"
:unread-only="false"
@ -34,16 +35,18 @@
</swiper-slide>
<swiper-slide>
<XNotifications
v-if="tab === 'reactions'"
:key="'tab2'"
class="notifications"
:include-types="['reaction']"
:unread-only="false"
/>
</swiper-slide>
<swiper-slide>
<XNotes :pagination="mentionsPagination" />
<XNotes v-if="tab === 'mentions'" :key="'tab3'" :pagination="mentionsPagination" />
</swiper-slide>
<swiper-slide>
<XNotes :pagination="directNotesPagination" />
<XNotes v-if="tab === 'directNotes'" :key="'tab4'" :pagination="directNotesPagination" />
</swiper-slide>
</swiper>
</MkSpacer>
@ -54,6 +57,7 @@
import { computed, ref, watch } from "vue";
import { Virtual } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/vue";
import type { Swiper as SwiperType } from "swiper/types";
import { notificationTypes } from "firefish-js";
import XNotifications from "@/components/MkNotifications.vue";
import XNotes from "@/components/MkNotes.vue";
@ -70,7 +74,7 @@ const tabs = ["all", "reactions", "mentions", "directNotes"];
const tab = ref(tabs[0]);
watch(tab, () => syncSlide(tabs.indexOf(tab.value)));
const includeTypes = ref<string[] | null>(null);
const includeTypes = ref<(typeof notificationTypes)[number][] | null>(null);
os.api("notifications/mark-all-as-read");
const MOBILE_THRESHOLD = 500;
@ -98,7 +102,7 @@ const directNotesPagination = {
function setFilter(ev) {
const typeItems = notificationTypes.map((t) => ({
text: i18n.t(`_notification._types.${t}`),
active: includeTypes.value && includeTypes.value.includes(t),
active: includeTypes.value?.includes(t),
action: () => {
includeTypes.value = [t];
},
@ -121,25 +125,23 @@ function setFilter(ev) {
}
const headerActions = computed(() =>
[
tab.value === "all"
? {
tab.value === "all"
? [
{
text: i18n.ts.filter,
icon: `${icon("ph-funnel")}`,
highlighted: includeTypes.value != null,
handler: setFilter,
}
: undefined,
tab.value === "all"
? {
},
{
text: i18n.ts.markAllAsRead,
icon: `${icon("ph-check")}`,
handler: () => {
os.apiWithDialog("notifications/mark-all-as-read");
},
}
: undefined,
].filter((x) => x !== undefined),
},
]
: [],
);
const headerTabs = computed(() => [
@ -172,18 +174,19 @@ definePageMetadata(
})),
);
let swiperRef = null;
let swiperRef: SwiperType | null = null;
function setSwiperRef(swiper) {
function setSwiperRef(swiper: SwiperType) {
swiperRef = swiper;
syncSlide(tabs.indexOf(tab.value));
}
function onSlideChange() {
tab.value = tabs[swiperRef.activeIndex];
if (tab.value !== tabs[swiperRef!.activeIndex])
tab.value = tabs[swiperRef!.activeIndex];
}
function syncSlide(index) {
swiperRef.slideTo(index);
function syncSlide(index: number) {
if (index !== swiperRef!.activeIndex) swiperRef!.slideTo(index);
}
</script>

View File

@ -14,6 +14,12 @@
>
</template>
</I18n>
<I18n :src="i18n.ts.i18nServerInfo" v-if="serverLang" tag="span">
<template #language><strong>{{ langs.find(a => a[0] === serverLang)?.[1] ?? serverLang }}</strong></template>
</I18n>
<button class="_textButton" @click="updateServerLang" v-if="lang && lang !== serverLang">
{{i18n.t(serverLang ? "i18nServerChange" : "i18nServerSet", { language: langs.find(a => a[0] === lang)?.[1] ?? lang })}}
</button>
</template>
</FormSelect>
@ -404,6 +410,7 @@ import { deviceKind } from "@/scripts/device-kind";
import icon from "@/scripts/icon";
const lang = ref(localStorage.getItem("lang"));
const serverLang = ref(me?.lang);
const translateLang = ref(localStorage.getItem("translateLang"));
const fontSize = ref(localStorage.getItem("fontSize"));
const useSystemFont = ref(localStorage.getItem("useSystemFont") !== "f");
@ -559,6 +566,14 @@ const foldNotification = computed(
// });
// }
function updateServerLang() {
os.api("i/update", {
lang: lang.value,
}).then((i) => {
serverLang.value = i.lang;
});
}
watch(swipeOnDesktop, () => {
defaultStore.set("swipeOnMobile", true);
});

View File

@ -265,14 +265,18 @@ export function getUserMenu(user, router: Router = mainRouter) {
icon: "ph-qr-code ph-bold ph-lg",
text: i18n.ts.getQrCode,
action: () => {
os.displayQrCode(`https://${host}/follow-me?acct=${user.username}`);
os.displayQrCode(
`https://${host}/follow-me?acct=${acct.toString(user)}`,
);
},
},
{
icon: `${icon("ph-hand-waving")}`,
text: i18n.ts.copyRemoteFollowUrl,
action: () => {
copyToClipboard(`https://${host}/follow-me?acct=${user.username}`);
copyToClipboard(
`https://${host}/follow-me?acct=${acct.toString(user)}`,
);
os.success();
},
},
@ -321,7 +325,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
icon: `${icon("ph-hand-waving")}`,
text: i18n.ts.remoteFollow,
action: () => {
router.push(`/follow-me?acct=${user.username}`);
router.push(`/follow-me?acct=${acct.toString(user)}`);
},
}
: undefined,

File diff suppressed because it is too large Load Diff

15
renovate.json Normal file
View File

@ -0,0 +1,15 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"rangeStrategy": "bump",
"branchConcurrentLimit": 5,
"enabledManagers": ["npm", "cargo"],
"baseBranches": ["develop"],
"lockFileMaintenance": {
"enabled": true,
"recreateWhen": "always",
"rebaseStalePrs": true,
"branchTopic": "lock-file-maintenance",
"commitMessageAction": "Lock file maintenance"
}
}

41
scripts/copy-assets.mjs Normal file
View File

@ -0,0 +1,41 @@
import fs from "node:fs/promises";
import path, { join } from "node:path";
import { fileURLToPath } from "node:url";
const repositoryRootDir = join(path.dirname(fileURLToPath(import.meta.url)), "../");
const file = (relativePath) => join(repositoryRootDir, relativePath);
await (async () => {
await fs.rm(file("built/_client_dist_/locales"), { recursive: true, force: true });
await Promise.all([
fs.cp(file("packages/backend/src/server/web"), file("packages/backend/built/server/web"), { recursive: true }),
fs.cp(file("custom/assets"), file("packages/backend/assets"), { recursive: true }),
fs.cp(file("packages/client/node_modules/three/examples/fonts"), file("built/_client_dist_/fonts"), { recursive: true }),
fs.mkdir(file("built/_client_dist_/locales"), { recursive: true }),
]);
const locales = (await import("../locales/index.mjs")).default;
const meta = (await import("../built/meta.json", { assert: { type: "json" } })).default;
for await (const [lang, locale] of Object.entries(locales)) {
await fs.writeFile(
file(`built/_client_dist_/locales/${lang}.${meta.version}.json`),
JSON.stringify({ ...locale, _version_: meta.version }),
"utf-8",
);
}
const js_assets = [
file("packages/backend/built/server/web/boot.js"),
file("packages/backend/built/server/web/bios.js"),
file("packages/backend/built/server/web/cli.js"),
];
for await (const js_file of js_assets) {
const content = (await fs.readFile(js_file, "utf-8"))
.replace("SUPPORTED_LANGS", JSON.stringify(Object.keys(locales)));
await fs.writeFile(js_file, content, "utf-8");
}
// TODO?: minify packages/backend/built/server/web/*.css
})();

View File

@ -1,6 +1,7 @@
import path, { join } from "node:path";
import { fileURLToPath } from "node:url";
import { execa } from "execa";
import fs from "node:fs";
(async () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -32,4 +33,18 @@ import { execa } from "execa";
stdio: "inherit",
}
);
if (!fs.existsSync(join(__dirname, "/../packages/backend-rs/built/index.js"))) {
fs.copyFileSync(
join(__dirname, "/../packages/backend-rs/index.js"),
join(__dirname, "/../packages/backend-rs/built/index.js"),
);
console.warn("backend-rs/built/index.js has not been updated (https://github.com/napi-rs/napi-rs/issues/1768)");
}
if (!fs.existsSync(join(__dirname, "/../packages/backend-rs/built/index.d.ts"))) {
fs.copyFileSync(
join(__dirname, "/../packages/backend-rs/index.d.ts"),
join(__dirname, "/../packages/backend-rs/built/index.d.ts"),
);
}
})();

View File

@ -11,12 +11,6 @@ import { execa } from "execa";
stderr: process.stderr,
});
execa("pnpm", ["dlx", "gulp", "watch"], {
cwd: join(__dirname, "/../"),
stdout: process.stdout,
stderr: process.stderr,
});
execa("pnpm", ["--filter", "backend", "watch"], {
cwd: join(__dirname, "/../"),
stdout: process.stdout,