Compare commits
No commits in common. "main" and "v0.6.0" have entirely different histories.
|
|
@ -1,18 +0,0 @@
|
|||
version = 1
|
||||
|
||||
test_patterns = ["**/*.test.ts"]
|
||||
|
||||
[[analyzers]]
|
||||
name = "shell"
|
||||
|
||||
[[analyzers]]
|
||||
name = "javascript"
|
||||
|
||||
[analyzers.meta]
|
||||
environment = ["nodejs"]
|
||||
|
||||
[[analyzers]]
|
||||
name = "docker"
|
||||
|
||||
[analyzers.meta]
|
||||
dockerfile_paths = ["Dockerfile"]
|
||||
10
.devcontainer/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Bun doesn't run well on Musl but this seems to work
|
||||
FROM oven/bun:1.1.9-alpine as base
|
||||
|
||||
RUN apk add --no-cache libstdc++ git bash curl openssh cloc
|
||||
|
||||
# Switch to Bash by editing /etc/passwd
|
||||
RUN sed -i -e 's|/bin/ash|/bin/bash|g' /etc/passwd
|
||||
|
||||
# Extract Node from its docker image (node:22-alpine)
|
||||
COPY --from=node:22-alpine /usr/local/bin/node /usr/local/bin/node
|
||||
34
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "Lysand Dev Container",
|
||||
"dockerFile": "Dockerfile",
|
||||
"runArgs": [
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/config:/workspace/config",
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/logs:/workspace/logs",
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/uploads:/workspace/uploads",
|
||||
"--network=host"
|
||||
],
|
||||
"mounts": [
|
||||
"source=node_modules,target=/workspace/node_modules,type=bind,consistency=cached",
|
||||
"type=bind,source=/home/${localEnv:USER}/.ssh,target=/root/.ssh,readonly"
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash"
|
||||
},
|
||||
"extensions": [
|
||||
"biomejs.biome",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"oven.bun-vscode",
|
||||
"vivaxy.vscode-conventional-commits",
|
||||
"EditorConfig.EditorConfig",
|
||||
"tamasfe.even-better-toml",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"ms-vscode-remote.remote-containers"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
tab_width = 4
|
||||
trim_trailing_whitespace = true
|
||||
39
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
Steps to reproduce the behavior, such as a cURL command, HTTP request, situation or code repository
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## Logs
|
||||
|
||||
Please upload logs onto a service like [Pastebin](https://pastebin.com/) or [Hastebin](https://hastebin.com/) and paste the link here. Don't paste the logs directly into the GitHub issue, as it just looks ugly and is hard to read.
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: [e.g. Fedora 39]
|
||||
- Bun version
|
||||
- Postgres version
|
||||
- Lysand commit ID or version
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the problem here.
|
||||
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Is your feature request related to a problem? Please describe.
|
||||
|
||||
A clear and concise description of what the problem is, such as "I'm always frustrated when [...]" or "I can't do [...]"
|
||||
|
||||
## Describe the solution you'd like
|
||||
|
||||
What would you like to see implemented?
|
||||
|
||||
## Describe alternatives you've considered
|
||||
|
||||
If applicable, describe any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
## Are you willing to work on this feature?
|
||||
|
||||
If you are willing to work on this feature, please say so here.
|
||||
549
.github/config.workflow.toml
vendored
|
|
@ -1,171 +1,106 @@
|
|||
# You can change the URL to the commit/tag you are using
|
||||
#:schema https://raw.githubusercontent.com/versia-pub/server/main/config/config.schema.json
|
||||
|
||||
# All values marked as "sensitive" can be set to "PATH:/path/to/file" to read the value from a file (e.g. a secret manager)
|
||||
|
||||
|
||||
[postgres]
|
||||
# PostgreSQL database configuration
|
||||
[database]
|
||||
host = "localhost"
|
||||
port = 5432
|
||||
username = "versia"
|
||||
# Sensitive value
|
||||
password = "versia"
|
||||
database = "versia"
|
||||
|
||||
# Additional read-only replicas
|
||||
# [[postgres.replicas]]
|
||||
# host = "other-host"
|
||||
# port = 5432
|
||||
# username = "versia"
|
||||
# password = "mycoolpassword2"
|
||||
# database = "replica1"
|
||||
username = "lysand"
|
||||
password = "lysand"
|
||||
database = "lysand"
|
||||
|
||||
[redis.queue]
|
||||
# A Redis database used for managing queues.
|
||||
# Required for federation
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
# Sensitive value
|
||||
# password = "test"
|
||||
password = ""
|
||||
database = 0
|
||||
|
||||
# A Redis database used for caching SQL queries.
|
||||
# Optional, can be the same as the queue instance
|
||||
# [redis.cache]
|
||||
# host = "localhost"
|
||||
# port = 6380
|
||||
# database = 1
|
||||
# password = ""
|
||||
|
||||
# Search and indexing configuration
|
||||
[search]
|
||||
# Enable indexing and searching?
|
||||
enabled = false
|
||||
|
||||
# Optional if search is disabled
|
||||
[search.sonic]
|
||||
[redis.cache]
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
password = ""
|
||||
database = 1
|
||||
enabled = false
|
||||
|
||||
[meilisearch]
|
||||
host = "localhost"
|
||||
port = 40007
|
||||
# Sensitive value
|
||||
password = ""
|
||||
api_key = ""
|
||||
enabled = false
|
||||
|
||||
[registration]
|
||||
# Can users sign up freely?
|
||||
allow = true
|
||||
# NOT IMPLEMENTED
|
||||
require_approval = false
|
||||
# Message to show to users when registration is disabled
|
||||
# message = "ran out of spoons to moderate registrations, sorry"
|
||||
[signups]
|
||||
# URL of your Terms of Service
|
||||
tos_url = "https://example.com/tos"
|
||||
# Whether to enable registrations or not
|
||||
registration = true
|
||||
rules = [
|
||||
"Do not harass others",
|
||||
"Be nice to people",
|
||||
"Don't spam",
|
||||
"Don't post illegal content",
|
||||
]
|
||||
|
||||
[oidc]
|
||||
# Run Lysand with this value missing to generate a new key
|
||||
jwt_key = "MC4CAQAwBQYDK2VwBCIEID+H5n9PY3zVKZQcq4jrnE1IiRd2EWWr8ApuHUXmuOzl;MCowBQYDK2VwAyEAzenliNkgpXYsh3gXTnAoUWzlCPjIOppmAVx2DBlLsC8="
|
||||
|
||||
[http]
|
||||
# URL that the instance will be accessible at
|
||||
base_url = "http://0.0.0.0:8080"
|
||||
# Address to bind to (0.0.0.0 is suggested for proxies)
|
||||
bind = "0.0.0.0"
|
||||
bind_port = 8080
|
||||
|
||||
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
|
||||
banned_ips = []
|
||||
# Banned user agents, regex format
|
||||
banned_user_agents = [
|
||||
# "curl\/7.68.0",
|
||||
# "wget\/1.20.3",
|
||||
]
|
||||
|
||||
# URL to an eventual HTTP proxy
|
||||
# Will be used for all outgoing requests
|
||||
# proxy_address = "http://localhost:8118"
|
||||
|
||||
# TLS configuration. You should probably be using a reverse proxy instead of this
|
||||
# [http.tls]
|
||||
# key = "/path/to/key.pem"
|
||||
# cert = "/path/to/cert.pem"
|
||||
# Sensitive value
|
||||
# passphrase = "awawa"
|
||||
# ca = "/path/to/ca.pem"
|
||||
|
||||
[frontend]
|
||||
# Enable custom frontends (warning: not enabling this will make Versia Server only accessible via the Mastodon API)
|
||||
# Frontends also control the OpenID flow, so if you disable this, you will need to use the Mastodon frontend
|
||||
enabled = true
|
||||
# Path that frontend files are served from
|
||||
# Edit this property to serve custom frontends
|
||||
# If this is not set, Versia Server will also check
|
||||
# the VERSIA_FRONTEND_PATH environment variable
|
||||
# path = ""
|
||||
|
||||
[frontend.routes]
|
||||
# Special routes for your frontend, below are the defaults for Versia-FE
|
||||
# Can be set to a route already used by Versia Server, as long as it is on a different HTTP method
|
||||
# e.g. /oauth/authorize is a POST-only route, so you can serve a GET route at /oauth/authorize
|
||||
# home = "/"
|
||||
# login = "/oauth/authorize"
|
||||
# consent = "/oauth/consent"
|
||||
# register = "/register"
|
||||
# password_reset = "/oauth/reset"
|
||||
|
||||
[frontend.settings]
|
||||
# Arbitrary key/value pairs to be passed to the frontend
|
||||
# This can be used to set up custom themes, etc on supported frontends.
|
||||
# theme = "dark"
|
||||
|
||||
# NOT IMPLEMENTED
|
||||
[email]
|
||||
# Enable email sending
|
||||
send_emails = false
|
||||
|
||||
# If send_emails is true, the following settings are required
|
||||
# [email.smtp]
|
||||
[smtp]
|
||||
# SMTP server to use for sending emails
|
||||
# server = "smtp.example.com"
|
||||
# port = 465
|
||||
# username = "test@example.com"
|
||||
# Sensitive value
|
||||
# password = "password123"
|
||||
# tls = true
|
||||
server = "smtp.example.com"
|
||||
port = 465
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
tls = true
|
||||
|
||||
[media]
|
||||
# Can be "s3" or "local", where "local" uploads the file to the local filesystem
|
||||
# Changing this value will not retroactively apply to existing data
|
||||
# Don't forget to fill in the s3 config :3
|
||||
# If you need to change this value after setting up your instance, you must move all the files
|
||||
# from one backend to the other manually
|
||||
backend = "local"
|
||||
# Whether to check the hash of media when uploading to avoid duplication
|
||||
deduplicate_media = true
|
||||
# If media backend is "local", this is the folder where the files will be stored
|
||||
# Can be any path
|
||||
uploads_path = "uploads"
|
||||
local_uploads_folder = "uploads"
|
||||
|
||||
[media.conversion]
|
||||
# Whether to automatically convert images to another format on upload
|
||||
convert_images = false
|
||||
# Can be: "image/jxl", "image/webp", "image/avif", "image/png", "image/jpeg", "image/heif", "image/gif"
|
||||
# Can be: "jxl", "webp", "avif", "png", "jpg", "heif"
|
||||
# JXL support will likely not work
|
||||
convert_to = "image/webp"
|
||||
# Also convert SVG images?
|
||||
convert_vectors = false
|
||||
convert_to = "webp"
|
||||
|
||||
# [s3]
|
||||
# Can be left commented if you don't use the S3 media backend
|
||||
# endpoint = "https://s3.example.com"
|
||||
# Sensitive value
|
||||
# access_key = "XXXXX"
|
||||
# Sensitive value
|
||||
# secret_access_key = "XXX"
|
||||
# region = "us-east-1"
|
||||
# bucket_name = "versia"
|
||||
# Can be left blank if you don't use the S3 media backend
|
||||
# endpoint = "https://s3-us-west-2.amazonaws.com"
|
||||
# access_key = ""
|
||||
# secret_access_key = ""
|
||||
# region = "us-west-2"
|
||||
# bucket_name = "lysand"
|
||||
# public_url = "https://cdn.example.com"
|
||||
|
||||
[validation]
|
||||
# Checks user data
|
||||
# Does not retroactively apply to previously entered data
|
||||
[validation.accounts]
|
||||
max_displayname_characters = 50
|
||||
max_username_characters = 30
|
||||
max_bio_characters = 5000
|
||||
max_avatar_bytes = 5_000_000
|
||||
max_header_bytes = 5_000_000
|
||||
# Regex is allowed here
|
||||
disallowed_usernames = [
|
||||
"well-known",
|
||||
# Self explanatory
|
||||
max_displayname_size = 50
|
||||
max_bio_size = 160
|
||||
max_note_size = 5000
|
||||
max_avatar_size = 5_000_000
|
||||
max_header_size = 5_000_000
|
||||
max_media_size = 40_000_000
|
||||
max_media_attachments = 10
|
||||
max_media_description_size = 1000
|
||||
max_poll_options = 20
|
||||
max_poll_option_size = 500
|
||||
min_poll_duration = 60
|
||||
max_poll_duration = 1893456000
|
||||
max_username_size = 30
|
||||
# An array of strings, defaults are from Akkoma
|
||||
username_blacklist = [
|
||||
".well-known",
|
||||
"~",
|
||||
"about",
|
||||
"activities",
|
||||
"api",
|
||||
|
|
@ -191,14 +126,12 @@ disallowed_usernames = [
|
|||
"search",
|
||||
"mfa",
|
||||
]
|
||||
max_field_count = 10
|
||||
max_field_name_characters = 1000
|
||||
max_field_value_characters = 1000
|
||||
max_pinned_notes = 20
|
||||
|
||||
[validation.notes]
|
||||
max_characters = 5000
|
||||
allowed_url_schemes = [
|
||||
# Whether to blacklist known temporary email providers
|
||||
blacklist_tempmail = false
|
||||
# Additional email providers to blacklist
|
||||
email_blacklist = []
|
||||
# Valid URL schemes, otherwise the URL is parsed as text
|
||||
url_scheme_whitelist = [
|
||||
"http",
|
||||
"https",
|
||||
"ftp",
|
||||
|
|
@ -216,259 +149,123 @@ allowed_url_schemes = [
|
|||
"mumble",
|
||||
"ssb",
|
||||
"gemini",
|
||||
] # NOT IMPLEMENTED
|
||||
|
||||
enforce_mime_types = false
|
||||
allowed_mime_types = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/webm",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/ogg",
|
||||
"audio/wave",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/x-pn-wave",
|
||||
"audio/vnd.wave",
|
||||
"audio/ogg",
|
||||
"audio/vorbis",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/webm",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/m4a",
|
||||
"audio/x-m4a",
|
||||
"audio/mp4",
|
||||
"audio/3gpp",
|
||||
"video/x-ms-asf",
|
||||
]
|
||||
max_attachments = 16
|
||||
|
||||
[validation.media]
|
||||
max_bytes = 40_000_000
|
||||
max_description_characters = 1000
|
||||
# An empty array allows all MIME types
|
||||
allowed_mime_types = []
|
||||
|
||||
[validation.emojis]
|
||||
max_bytes = 1_000_000
|
||||
max_shortcode_characters = 100
|
||||
max_description_characters = 1000
|
||||
|
||||
[validation.polls]
|
||||
max_options = 20
|
||||
max_option_characters = 500
|
||||
min_duration_seconds = 60
|
||||
# 100 days
|
||||
max_duration_seconds = 8_640_000
|
||||
|
||||
[validation.emails]
|
||||
# Blocks over 10,000 common tempmail domains
|
||||
disallow_tempmail = false
|
||||
# Regex is allowed here
|
||||
disallowed_domains = []
|
||||
|
||||
[validation.challenges]
|
||||
# "Challenges" (aka captchas) are a way to verify that a user is human
|
||||
# Versia Server's challenges use no external services, and are proof-of-work based
|
||||
# This means that they do not require any user interaction, instead
|
||||
# they require the user's computer to do a small amount of work
|
||||
# The difficulty of the challenge, higher is will take more time to solve
|
||||
difficulty = 50000
|
||||
# Challenge expiration time in seconds
|
||||
expiration = 300 # 5 minutes
|
||||
# Leave this empty to generate a new key
|
||||
# Sensitive value
|
||||
key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg=="
|
||||
|
||||
# Block content that matches these regular expressions
|
||||
[validation.filters]
|
||||
note_content = [
|
||||
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
|
||||
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
|
||||
]
|
||||
emoji_shortcode = []
|
||||
username = []
|
||||
displayname = []
|
||||
bio = []
|
||||
|
||||
[notifications]
|
||||
|
||||
# Web Push Notifications configuration.
|
||||
# Leave out to disable.
|
||||
[notifications.push]
|
||||
# Subject field embedded in the push notification
|
||||
# subject = "mailto:joe@example.com"
|
||||
#
|
||||
[notifications.push.vapid_keys]
|
||||
# VAPID keys for push notifications
|
||||
# Run Versia Server with those values missing to generate new keys
|
||||
# Sensitive value
|
||||
public = "BBanhyj2_xWwbTsWld3T49VcAoKZHrVJTzF1f6Av2JwQY_wUi3CF9vZ0WeEcACRj6EEqQ7N35CkUh5epF7n4P_s"
|
||||
# Sensitive value
|
||||
private = "Eujaz7NsF0rKZOVrAFL7mMpFdl96f591ERsRn81unq0"
|
||||
|
||||
[defaults]
|
||||
# Default visibility for new notes
|
||||
# Can be public, unlisted, private or direct
|
||||
# Private only sends to followers, unlisted doesn't show up in timelines
|
||||
visibility = "public"
|
||||
# Default language for new notes (ISO code)
|
||||
# Default language for new notes
|
||||
language = "en"
|
||||
# Default avatar, must be a valid URL or left out for a placeholder avatar
|
||||
# Default avatar, must be a valid URL or ""
|
||||
# avatar = ""
|
||||
# Default header, must be a valid URL or left out for none
|
||||
# Default header, must be a valid URL or ""
|
||||
# header = ""
|
||||
# A style name from https://www.dicebear.com/styles
|
||||
placeholder_style = "thumbs"
|
||||
|
||||
[queues]
|
||||
# Controls the delivery queue (for outbound federation)
|
||||
[queues.delivery]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
[activitypub]
|
||||
# Use ActivityPub Tombstones instead of deleting objects
|
||||
use_tombstones = true
|
||||
# Fetch all members of collections (followers, following, etc) when receiving them
|
||||
# WARNING: This can be a lot of data, and is not recommended
|
||||
fetch_all_collection_members = false # NOT IMPLEMENTED
|
||||
|
||||
# Controls the inbox processing queue (for inbound federation)
|
||||
[queues.inbox]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
# The following values must be instance domain names without "https" or glob patterns
|
||||
# Rejects all activities from these instances (fediblocking)
|
||||
reject_activities = []
|
||||
# Force posts from this instance to be followers only
|
||||
force_followers_only = [] # NOT IMPLEMENTED
|
||||
# Discard all reports from these instances
|
||||
discard_reports = [] # NOT IMPLEMENTED
|
||||
# Discard all deletes from these instances
|
||||
discard_deletes = []
|
||||
# Discard all updates (edits) from these instances
|
||||
discard_updates = []
|
||||
# Discard all banners from these instances
|
||||
discard_banners = [] # NOT IMPLEMENTED
|
||||
# Discard all avatars from these instances
|
||||
discard_avatars = [] # NOT IMPLEMENTED
|
||||
# Discard all follow requests from these instances
|
||||
discard_follows = []
|
||||
# Force set these instances' media as sensitive
|
||||
force_sensitive = [] # NOT IMPLEMENTED
|
||||
# Remove theses instances' media
|
||||
remove_media = [] # NOT IMPLEMENTED
|
||||
|
||||
# Controls the fetch queue (for remote data refreshes)
|
||||
[queues.fetch]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the push queue (for push notification delivery)
|
||||
[queues.push]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the media queue (for media processing)
|
||||
[queues.media]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
[federation]
|
||||
# This is a list of domain names, such as "mastodon.social" or "pleroma.site"
|
||||
# These changes will not retroactively apply to existing data before they were changed
|
||||
# For that, please use the CLI (in a later release)
|
||||
|
||||
# These instances will not be federated with
|
||||
blocked = []
|
||||
# These instances' data will only be shown to followers, not in public timelines
|
||||
followers_only = []
|
||||
|
||||
[federation.discard]
|
||||
# These objects will be discarded when received from these instances
|
||||
reports = []
|
||||
deletes = []
|
||||
updates = []
|
||||
media = []
|
||||
follows = []
|
||||
# If instance reactions are blocked, likes will also be discarded
|
||||
likes = []
|
||||
reactions = []
|
||||
banners = []
|
||||
avatars = []
|
||||
|
||||
# For bridge software, such as versia-pub/activitypub
|
||||
# Bridges must be hosted separately from the main Versia Server process
|
||||
# [federation.bridge]
|
||||
# Only versia-ap exists for now
|
||||
# software = "versia-ap"
|
||||
# If this is empty, any bridge with the correct token
|
||||
# will be able to send data to your instance
|
||||
# v4, v6, ranges and wildcards are supported
|
||||
# allowed_ips = ["192.168.1.0/24"]
|
||||
# Token for the bridge software
|
||||
# Bridge must have the same token!
|
||||
# Sensitive value
|
||||
# token = "mycooltoken"
|
||||
# url = "https://ap.versia.social"
|
||||
# Whether to verify HTTP signatures for every request (warning: can slow down your server
|
||||
# significantly depending on processing power)
|
||||
authorized_fetch = false
|
||||
|
||||
[instance]
|
||||
name = "Versia"
|
||||
description = "A Versia Server instance"
|
||||
name = "Lysand"
|
||||
description = "A test instance of Lysand"
|
||||
# URL to your instance logo (jpg files should be renamed to jpeg)
|
||||
# logo = ""
|
||||
# URL to your instance banner (jpg files should be renamed to jpeg)
|
||||
# banner = ""
|
||||
|
||||
# Paths to instance long description, terms of service, and privacy policy
|
||||
# These will be parsed as Markdown
|
||||
#
|
||||
# extended_description_path = "config/extended_description.md"
|
||||
# tos_path = "config/tos.md"
|
||||
# privacy_policy_path = "config/privacy_policy.md"
|
||||
|
||||
# Primary instance languages. ISO 639-1 codes.
|
||||
languages = ["en"]
|
||||
|
||||
[instance.contact]
|
||||
email = "staff@yourinstance.com"
|
||||
|
||||
[instance.branding]
|
||||
# logo = "https://cdn.example.com/logo.png"
|
||||
# banner = "https://cdn.example.com/banner.png"
|
||||
|
||||
# Used for federation. If left empty or missing, the server will generate one for you.
|
||||
[instance.keys]
|
||||
# Sensitive value
|
||||
public = "MCowBQYDK2VwAyEASN0V5OWRbhRCnuhxfRLqpUOfszHozvrLLVhlIYLNTZM="
|
||||
# Sensitive value
|
||||
private = "MC4CAQAwBQYDK2VwBCIEIKaxDGMaW71OcCGMY+GKTZPtLPNlTvMFe3G5qXVHPhQM"
|
||||
|
||||
[[instance.rules]]
|
||||
# Short description of the rule
|
||||
text = "No hate speech"
|
||||
# Longer version of the rule with additional information
|
||||
hint = "Hate speech includes slurs, threats, and harassment."
|
||||
|
||||
[[instance.rules]]
|
||||
text = "No spam"
|
||||
|
||||
# [[instance.rules]]
|
||||
# ...etc
|
||||
|
||||
[permissions]
|
||||
# Control default permissions for users
|
||||
# Note that an anonymous user having a permission will not allow them
|
||||
# to do things that require authentication (e.g. 'owner:notes' -> posting a note will need
|
||||
# auth, but viewing a note will not)
|
||||
# See https://server.versia.pub/api/roles#list-of-permissions for a list of all permissions
|
||||
|
||||
# Defaults to being able to login and manage their own content
|
||||
# anonymous = []
|
||||
|
||||
# Defaults to identical to anonymous
|
||||
# default = []
|
||||
|
||||
# Defaults to being able to manage all instance data, content, and users
|
||||
# admin = []
|
||||
[filters]
|
||||
# Drop notes with these regex filters (only applies to new activities)
|
||||
note_filters = [
|
||||
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
|
||||
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
|
||||
]
|
||||
# Drop users with these regex filters (only applies to new activities)
|
||||
username_filters = []
|
||||
# Drop users with these regex filters (only applies to new activities)
|
||||
displayname_filters = []
|
||||
# Drop users with these regex filters (only applies to new activities)
|
||||
bio_filters = []
|
||||
emoji_filters = [] # NOT IMPLEMENTED
|
||||
|
||||
[logging]
|
||||
# Log all requests (warning: this is a lot of data)
|
||||
log_requests = true
|
||||
# Log request and their contents (warning: this is a lot of data)
|
||||
log_requests_verbose = false
|
||||
# For GDPR compliance, you can disable logging of IPs
|
||||
log_ip = false
|
||||
|
||||
# Available levels: trace, debug, info, warning, error, fatal
|
||||
log_level = "info" # For console output
|
||||
# Log all filtered objects
|
||||
log_filters = true
|
||||
|
||||
# [logging.file]
|
||||
# path = "logs/versia.log"
|
||||
# log_level = "info"
|
||||
#
|
||||
# [logging.file.rotation]
|
||||
# max_size = 10_000_000 # 10 MB
|
||||
# max_files = 10 # Keep 10 rotated files
|
||||
#
|
||||
# https://sentry.io support
|
||||
# [logging.sentry]
|
||||
# dsn = "https://example.com"
|
||||
# debug = false
|
||||
# sample_rate = 1.0
|
||||
# traces_sample_rate = 1.0
|
||||
# Can also be regex
|
||||
# trace_propagation_targets = []
|
||||
# max_breadcrumbs = 100
|
||||
# environment = "production"
|
||||
# log_level = "info"
|
||||
[ratelimits]
|
||||
# Amount to multiply every route's duration by
|
||||
duration_coeff = 1.0
|
||||
# Amount to multiply every route's max by
|
||||
max_coeff = 1.0
|
||||
|
||||
[authentication]
|
||||
# Run Versia Server with this value missing to generate a new key
|
||||
key = "ZWcwanRaQAqY3ChUro/Jey9XGQjzsxEed5iqTp4yFr8W6vEnXdz91F/Pu/uf7HBMbNeIK7V6aHsM0lq9onrO8Q=="
|
||||
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
# Most notably, GitHub does not support this
|
||||
# Redirect URLs in your OpenID provider can be set to this:
|
||||
# <base_url>/oauth/sso/<provider_id>/callback*
|
||||
# The asterisk is important, as it allows for any query parameters to be passed
|
||||
# Authentik for example uses regex so it can be set to (regex):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
# [[authentication.openid_providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||
# url = "https://id.cpluspatch.com/application/o/versia-testing/"
|
||||
# client_id = "XXXX"
|
||||
# Sensitive value
|
||||
# client_secret = "XXXXX"
|
||||
# icon = "https://cpluspatch.com/images/icons/logo.svg"
|
||||
[custom_ratelimits]
|
||||
# Add in any API route in this style here
|
||||
"/api/v1/timelines/public" = { duration = 60, max = 200 }
|
||||
|
|
|
|||
22
.github/copilot-instructions.md
vendored
|
|
@ -1,22 +0,0 @@
|
|||
We use full TypeScript and ESM with Bun for our codebase. Please include relevant and detailed JSDoc comments for all functions and classes. Use explicit type annotations for all variables and function return values, such as:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Adds two numbers together.
|
||||
*
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {number}
|
||||
*/
|
||||
const add = (a: number, b: number): number => a + b;
|
||||
```
|
||||
|
||||
We always write TypeScript with double quotes and four spaces for indentation, so when your responses include TypeScript code, please follow those conventions.
|
||||
|
||||
Our codebase uses Drizzle as an ORM, which is exposed in the `@versia-server/kit/db` and `@versia-server/kit/tables` packages. This project uses a monorepo structure with Bun as the package manager.
|
||||
|
||||
The app has two modes: worker and API. The worker mode is used for background tasks, while the API mode serves HTTP requests. The entry point for the worker is `worker.ts`, and for the API, it is `api.ts`.
|
||||
|
||||
Run the typechecker with `bun run typecheck` to ensure that all TypeScript code is type-checked correctly. Run tests with `bun test` to ensure that all tests pass. Run the linter and formatter with `bun lint` to ensure that the code adheres to our style guidelines, and `bun lint --write` to automatically fix minor/formatting issues.
|
||||
|
||||
Cover all new functionality with tests, and ensure that all tests pass before submitting your code.
|
||||
27
.github/workflows/check.yml
vendored
|
|
@ -1,27 +0,0 @@
|
|||
name: Check Types
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
bun install
|
||||
|
||||
- name: Run typechecks
|
||||
run: |
|
||||
bun run typecheck
|
||||
27
.github/workflows/circular-imports.yml
vendored
|
|
@ -1,27 +0,0 @@
|
|||
name: Check Circular Imports
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
bun install
|
||||
|
||||
- name: Run typechecks
|
||||
run: |
|
||||
bun run detect-circular
|
||||
10
.github/workflows/codeql.yml
vendored
|
|
@ -9,7 +9,7 @@
|
|||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL Scan"
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -46,11 +46,11 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
|
@ -76,6 +76,6 @@ jobs:
|
|||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
|
|
|||
67
.github/workflows/docker-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
# Publish semver tags as releases.
|
||||
tags: [ 'v*.*.*' ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3 # v3.0.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3 # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
98
.github/workflows/docker.yml
vendored
|
|
@ -1,98 +0,0 @@
|
|||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
check:
|
||||
uses: ./.github/workflows/check.yml
|
||||
|
||||
tests:
|
||||
uses: ./.github/workflows/tests.yml
|
||||
|
||||
detect-circular:
|
||||
uses: ./.github/workflows/circular-imports.yml
|
||||
|
||||
build:
|
||||
if: ${{ success() }}
|
||||
needs: [lint, check, tests, detect-circular]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- container: worker
|
||||
image_name: ${{ github.repository_owner }}/worker
|
||||
dockerfile: Worker.Dockerfile
|
||||
- container: server
|
||||
image_name: ${{ github.repository_owner }}/server
|
||||
dockerfile: Dockerfile
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ matrix.image_name }}
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Get the commit hash
|
||||
run: echo "GIT_COMMIT=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ env.GIT_COMMIT }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
56
.github/workflows/docs.yml
vendored
|
|
@ -1,56 +0,0 @@
|
|||
name: Deploy Docs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Pages
|
||||
|
||||
uses: actions/configure-pages@v4
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Build with VitePress
|
||||
run: bun run --filter="@versia-server/api" docs:build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: packages/api/docs/.vitepress/dist
|
||||
|
||||
# Deployment job
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
27
.github/workflows/lint.yml
vendored
|
|
@ -1,27 +0,0 @@
|
|||
name: Lint & Format
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
bun install
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
bunx @biomejs/biome ci .
|
||||
8
.github/workflows/mirror.yml
vendored
|
|
@ -1,8 +0,0 @@
|
|||
name: Mirror to Codeberg
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
name: Mirror
|
||||
uses: versia-pub/.github/.github/workflows/mirror.yml@main
|
||||
secrets: inherit
|
||||
25
.github/workflows/nix-flake.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
name: Nix Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
extra-conf: accept-flake-config = true
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
- uses: DeterminateSystems/flake-checker-action@main
|
||||
- name: Build default package
|
||||
run: nix build .
|
||||
- name: Check flakes
|
||||
run: nix flake check --allow-import-from-derivation
|
||||
48
.github/workflows/publish.yml
vendored
|
|
@ -1,48 +0,0 @@
|
|||
name: Build & Publish Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to publish"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- client
|
||||
- sdk
|
||||
tag:
|
||||
description: "NPM tag to use"
|
||||
required: true
|
||||
type: choice
|
||||
default: nightly
|
||||
options:
|
||||
- latest
|
||||
- nightly
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# For provenance generation
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: NPM Deploy
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Publish to NPM
|
||||
working-directory: packages/${{ inputs.package }}
|
||||
run: bun publish --provenance --tag ${{ inputs.tag }} --access public
|
||||
env:
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish to JSR
|
||||
working-directory: packages/${{ inputs.package }}
|
||||
run: bunx jsr publish --allow-slow-types --allow-dirty
|
||||
36
.github/workflows/test-publish.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: Test Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# For provenance generation
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
# Build job
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
environment: NPM Deploy
|
||||
strategy:
|
||||
matrix:
|
||||
package: ["sdk", "client"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Publish to NPM
|
||||
working-directory: packages/${{ matrix.package }}
|
||||
env:
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bun publish --dry-run --access public
|
||||
|
||||
- name: Publish to JSR
|
||||
working-directory: packages/${{ matrix.package }}
|
||||
run: bunx jsr publish --allow-slow-types --allow-dirty --dry-run
|
||||
32
.github/workflows/tests.yml
vendored
|
|
@ -1,44 +1,40 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ["*"]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/lysand-org/postgres:main
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_DB: versia
|
||||
POSTGRES_USER: versia
|
||||
POSTGRES_PASSWORD: versia
|
||||
POSTGRES_DB: lysand
|
||||
POSTGRES_USER: lysand
|
||||
POSTGRES_PASSWORD: lysand
|
||||
volumes:
|
||||
- versia-data:/var/lib/postgresql/data
|
||||
- lysand-data:/var/lib/postgresql/data
|
||||
options: --health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
|
|
@ -50,4 +46,4 @@ jobs:
|
|||
|
||||
- name: Run tests
|
||||
run: |
|
||||
bun run test
|
||||
bun test
|
||||
|
|
|
|||
15
.gitignore
vendored
|
|
@ -117,10 +117,6 @@ out
|
|||
.nuxt
|
||||
dist
|
||||
|
||||
# Nix build output
|
||||
|
||||
result
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
|
@ -179,12 +175,11 @@ log.txt
|
|||
*.log
|
||||
build
|
||||
config/extended_description_test.md
|
||||
glitch-old
|
||||
glitch
|
||||
glitch.tar.gz
|
||||
glitch-dev
|
||||
*.pem
|
||||
oclif.manifest.json
|
||||
.direnv/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Vitepress Docs
|
||||
|
||||
*/.vitepress/dist
|
||||
*/.vitepress/cache
|
||||
tsconfig.tsbuildinfo
|
||||
4
.husky/pre-commit
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
PATH=$PATH:~/.bun/bin
|
||||
bun install
|
||||
bun lint --apply
|
||||
bun check
|
||||
13
.vscode/extensions.json
vendored
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"oven.bun-vscode",
|
||||
"vivaxy.vscode-conventional-commits",
|
||||
"EditorConfig.EditorConfig",
|
||||
"tamasfe.even-better-toml",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"eamodio.gitlens"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
47
.vscode/launch.json
vendored
|
|
@ -1,48 +1,15 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"type": "node",
|
||||
"name": "vscode-jest-tests.v2.lysand",
|
||||
"request": "launch",
|
||||
"name": "Debug File",
|
||||
"program": "${file}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"stopOnEntry": false,
|
||||
"watchMode": false
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"args": ["test", "${jest.testFile}"],
|
||||
"cwd": "/home/jessew/Dev/lysand",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "launch",
|
||||
"name": "Run File",
|
||||
"program": "${file}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"watchMode": false
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "attach",
|
||||
"name": "Attach Bun",
|
||||
"url": "ws://localhost:6499/",
|
||||
"stopOnEntry": false
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "launch",
|
||||
"name": "Run index.ts",
|
||||
"program": "${workspaceFolder}/index.ts",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"watchMode": true
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "launch",
|
||||
"name": "Run tests",
|
||||
"program": "test"
|
||||
"disableOptimisticBPs": true,
|
||||
"program": "/home/jessew/.bun/bin/bun"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
11
.vscode/settings.json
vendored
|
|
@ -1,15 +1,12 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"jest.jestCommandLine": "/home/jessew/.bun/bin/bun test",
|
||||
"jest.rootPath": ".",
|
||||
"conventionalCommits.scopes": [
|
||||
"database",
|
||||
"api",
|
||||
"cli",
|
||||
"federation",
|
||||
"config",
|
||||
"worker",
|
||||
"media",
|
||||
"packages/client",
|
||||
"packages/sdk"
|
||||
],
|
||||
"languageToolLinter.languageTool.ignoredWordsInWorkspace": ["versia"]
|
||||
"config"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
184
CHANGELOG.md
|
|
@ -1,184 +0,0 @@
|
|||
# `0.9.0` (upcoming)
|
||||
|
||||
## Features
|
||||
|
||||
### API
|
||||
|
||||
- [x] 🥺 Emoji Reactions are now available! You can react to any note with custom emojis.
|
||||
- [x] 🔎 Added support for [batch account data API](https://docs.joinmastodon.org/methods/accounts/#index).
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] 🚀 Upgraded Bun to `1.3.2`
|
||||
|
||||
# `0.8.0` • Federation 2: Electric Boogaloo
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
Versia Server `0.8.0` is **not** backwards-compatible with `0.7.0`. This release includes some breaking changes to the database schema and configuration file.
|
||||
|
||||
Please see [Database Changes](#database-changes) and [New Configuration](#new-configuration) for more information.
|
||||
|
||||
## Features
|
||||
|
||||
### Federation
|
||||
|
||||
- [x] 🦄 Updated to [`Versia 0.5`](https://versia.pub/changelog).
|
||||
- [x] 📦 Added support for new Versia features:
|
||||
- [x] [**Instance Messaging Extension**](https://versia.pub/extensions/instance-messaging)
|
||||
- [x] [**Shared Inboxes**](https://versia.pub/federation#inboxes)
|
||||
- [x] 🔗 Changed entity URIs to be more readable (`example.org/objects/:id` → `example.org/{notes,likes,...}/:id`)
|
||||
|
||||
### API
|
||||
|
||||
- [x] 📲 Added [Push Notifications](https://docs.joinmastodon.org/methods/push) support.
|
||||
- [x] 📖 Overhauled OpenAPI schemas to match [Mastodon API docs](https://docs.joinmastodon.org)
|
||||
- [x] 👷 Improved [**Roles API**](https://server.versia.pub/api/roles) to allow for full role control (create, update, delete, assign).
|
||||
- [x] ✏️ `<div>` and `<span>` tags are now allowed in Markdown.
|
||||
- [x] 🔥 Removed nonstandard `/api/v1/accounts/id` endpoint (the same functionality was already possible with other endpoints).
|
||||
- [x] ✨️ Implemented rate limiting support for API endpoints.
|
||||
- [x] 🔒 Implemented `is_indexable` and `is_hiding_collections` fields to the [**Accounts API**](https://docs.joinmastodon.org/methods/accounts/#update_credentials).
|
||||
- [x] ✨️ Muting other users now lets you specify a duration, after which the mute will be automatically removed.
|
||||
- [x] 📰 All accounts now have an RSS/Atom feed attached to them.
|
||||
|
||||
### CLI
|
||||
|
||||
- [x] ⌨️ New commands!
|
||||
- [x] ✨️ `cli user token` to generate API tokens.
|
||||
- [x] 👷 Error messages are now prettier!
|
||||
|
||||
### Frontend
|
||||
|
||||
The way frontend is built and served has been changed. In the past, it was required to have a second process serving a frontend, which `versia-server` would proxy requests to. This is no longer the case.
|
||||
|
||||
Versia Server now serves static files directly from a configurable path, and `versia-fe` has been updated to support this.
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] 🚀 Upgraded Bun to `1.2.13`
|
||||
- [x] 🔥 Removed dependency on the `pg_uuidv7` extension. Versia Server can now be used with "vanilla" PostgreSQL.
|
||||
- [x] 🖼️ Simplified media pipeline: this will improve S3 performance
|
||||
- [x] 📈 It is now possible to disable media proxying for your CDN (offloading considerable bandwidth to your more optimized CDN).
|
||||
- [x] 👷 Outbound federation, inbox processing, data fetching and media processing are now handled by a queue system.
|
||||
- [x] 🌐 An administration panel is available at `/admin/queues` to monitor and manage queues.
|
||||
- [x] 🔥 Removed support for **from-source** installations, as Versia Server is designed around containerization and maintaining support was a large burden.
|
||||
- [x] ❄️ A [**Nix**](https://nixos.org/) package is now available for this project, packaged as a [Flake](https://wiki.nixos.org/wiki/Flakes). A **NixOS** module is also provided.
|
||||
|
||||
## New Configuration
|
||||
|
||||
Configuration parsing and validation has been overhauled. Unfortunately, this means that since a bunch of options have been renamed, you'll need to redownload [the default configuration file](config/config.example.toml) and reapply your changes.
|
||||
|
||||
## Database Changes
|
||||
|
||||
Various media-related attributes have been merged into a single `Medias` table. This will require a migration in order to preserve the old data.
|
||||
|
||||
Since very few instances are running `0.7.0`, we have decided to "rawdog it" instead of making a proper migration script (as that would take a ton of time that we don't have).
|
||||
|
||||
In the case that you've been running secret instances in the shadows, let us know and we'll help you out.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- 🐛 All URIs in custom Markdown text are now correctly proxied.
|
||||
- 🐛 Fixed several issues with the [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub) preventing it from operating properly.
|
||||
- 🐛 Fixed incorrect content-type on some media when using S3.
|
||||
- 🐛 All media content-type is now correctly fetched, instead of guessed from the file extension as before.
|
||||
- 🐛 Fixed OpenAPI schema generation and `/docs` endpoint.
|
||||
- 🐛 Logs folder is now automatically created if it doesn't exist.
|
||||
- 🐛 Media hosted on the configured S3 bucket and on the local filesystem is no longer unnecessarily proxied.
|
||||
- 🐛 Likes and Shares now federate properly.
|
||||
|
||||
# `0.7.0` • The Auth and APIs Update
|
||||
|
||||
> [!WARNING]
|
||||
> This release marks the rename of the project from `Lysand` to `Versia`.
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
Versia Server `0.7.0` is backwards compatible with `0.6.0`. However, some new features may not be available to older clients. Notably, `versia-fe` has had major improvements and will not work with `0.6.0`.
|
||||
|
||||
## Features
|
||||
|
||||
- Upgraded Bun to `1.1.34`. This brings performance upgrades and better stability.
|
||||
- Added support for the [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub).
|
||||
- Added support for the [Sonic](https://github.com/valeriansaliou/sonic) search indexer.
|
||||
- Note deletions are now federated.
|
||||
- Note edits are now federated.
|
||||
- Added support for [Sentry](https://sentry.io).
|
||||
- Added option for more federation debug logging.
|
||||
- Added [**Roles API**](https://server.versia.pub/api/roles).
|
||||
- Added [**Permissions API**](https://server.versia.pub/api/roles) and enabled it for every route.
|
||||
- Added [**TOS and Privacy Policy**](https://server.versia.pub/api/mastodon) endpoints.
|
||||
- Added [**Challenge API**](https://server.versia.pub/api/challenges). (basically CAPTCHAS). This can be enabled/disabled by administrators. No `versia-fe` support yet.
|
||||
- Added ability to refetch user data from remote instances.
|
||||
- Added ability to change the `username` of a user. ([Mastodon API extension](https://server.versia.pub/api/mastodon#api-v1-accounts-update-credentials)).
|
||||
- Added an endpoint to get a user by its username.
|
||||
- Add OpenID Connect registration support. Admins can now disable username/password registration entirely and still allow users to sign up via OpenID Connect.
|
||||
- Add option to never convert vector images to a raster format.
|
||||
- Refactor logging system to be more robust and easier to use. Log files are now automatically rotated.
|
||||
- Add support for HTTP proxies.
|
||||
- Add support for serving Versia over a Tor hidden service.
|
||||
- Add global server error handler, to properly return 500 error messages to clients.
|
||||
- Sign all federation HTTP requests.
|
||||
- Add JSON schema for configuration file.
|
||||
- Rewrite federation stack
|
||||
- Updated federation to Versia 0.4
|
||||
- Implement OAuth2 token revocation
|
||||
- Add new **Plugin API**
|
||||
|
||||
## Plugin System
|
||||
|
||||
A new plugin system for extending Versia Server has been added in this release!
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> This is an internal feature and is not documented. Support for third-party plugins will be given on a "if we have time" basis, until the API is fully stabilized and documented
|
||||
|
||||
Plugins using this framework support:
|
||||
|
||||
- [x] Plugin hotswapping and hotreloading
|
||||
- [x] Manifest files (JSON, JSON5, JSONC supported) with metadata (JSON schema provided)
|
||||
- [x] Installation by dropping a folder into the plugins/ directory
|
||||
- [x] Support for plugins having their own NPM dependencies
|
||||
- [x] Support for storing plugins' configuration in the main config.toml (single source of truth)
|
||||
- [x] Schema-based strict config validation (plugins can specify their own schemas)
|
||||
- [x] Full type-safety
|
||||
- [x] Custom hooks
|
||||
- [x] FFI compatibility (with `bun:ffi` or Node's FFI)
|
||||
- [x] Custom API route registration or overriding or middlewaring
|
||||
- [x] Automatic OpenAPI schema generation for all installed plugins
|
||||
- [x] End-to-end and unit testing supported
|
||||
- [x] Automatic user input validation for API routes with schemas (specify a schema for the route and the server will take care of validating everything)
|
||||
- [x] Access to internal database abstractions
|
||||
- [x] Support for sending raw SQL to database (type-safe!)
|
||||
- [x] Plugin autoload on startup with override controls (enable/disable)
|
||||
|
||||
As a demonstration of the power of this system and an effort to modularize the codebase further, OpenID functionality has been moved to a plugin. This plugin is required for login.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix favouriting/unfavouriting sometimes returning negative counts.
|
||||
- Non-images will now properly be uploaded to object storage.
|
||||
- Make account searches case-insensitive
|
||||
- Fix image decoding error when passing media through proxy.
|
||||
- OpenID Connect now correctly remembers and passes `state` parameter.
|
||||
- OpenID Connect will not reject some correct but weird redirect URIs.
|
||||
- Markdown posts will not have invisible anchor tags anymore (this messed up accessibility).
|
||||
- Reverse proxies incorrectly reporting an HTTPS request as HTTP will now be handled correctly during OpenID Connect flows.
|
||||
- API Relationships will now correctly return `requested_by`.
|
||||
- Make process wait for Ctrl+C to exit on error, instead of exiting immediately. This fixes some issues with Docker restarting endlessly.
|
||||
- Animated media will now stay animated when uploaded.
|
||||
- Some instance metadata will no longer be missing from `/api/v2/instabnce` endpoint. In fact, it will now be more complete than Mastodon's implementation.
|
||||
- The Origin HTTP header will no longer be used to determine the origin of a request. This was a security issue.
|
||||
- New notes will no longer incorrectly be federated to _all_ remote users at once.
|
||||
- Fix [Elk Client](https://elk.zone/) not being able to log in.
|
||||
|
||||
## Removals
|
||||
|
||||
- Remove old logging system, to be replaced by a new one.
|
||||
- Removed Meilisearch support, in favor of Sonic. Follow instructions in the [installation guide](https://server.versia.pub/setup/installation) to set up Sonic.
|
||||
- Removed explicit Glitch-FE support. Glitch-FE will still work, but must be hosted separately like any other frontend.
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
- Remove Node.js from Docker build.
|
||||
- Update all dependencies.
|
||||
140
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement:
|
||||
|
||||
- CPlusPatch
|
||||
- Matrix: @jesse:cpluspatch.dev
|
||||
- E-Mail: contact@cpluspatch.com
|
||||
- AprilThePimk
|
||||
- Matrix: @aprl:uwu.is
|
||||
- E-Mail: aprl+fossawareness@acab.dev
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
# Contributing to Versia
|
||||
# Contributing to Lysand
|
||||
|
||||
> [!NOTE]
|
||||
> This document was authored by [@CPlusPatch](https://github.com/CPlusPatch).
|
||||
|
||||
Thank you for your interest in contributing to Versia Server! We welcome contributions from everyone, regardless of their level of experience or expertise.
|
||||
Thank you for your interest in contributing to Lysand! We welcome contributions from everyone, regardless of their level of experience or expertise.
|
||||
|
||||
# Tech Stack
|
||||
|
||||
Versia Server is built using the following technologies:
|
||||
Lysand is built using the following technologies:
|
||||
|
||||
- [Bun](https://bun.sh) - A JavaScript runtime similar to Node.js, but faster and with more features
|
||||
- [PostgreSQL](https://www.postgresql.org/) - A relational database
|
||||
|
|
@ -20,54 +20,26 @@ Versia Server is built using the following technologies:
|
|||
|
||||
To get started, please follow these steps:
|
||||
|
||||
1. Install the [Bun](https://bun.sh) runtime:
|
||||
1. Fork the repository, clone it on your local system and make your own branch
|
||||
2. Install the [Bun](https://bun.sh) runtime:
|
||||
```sh
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
2. Clone this repository
|
||||
1. Clone this repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/versia-pub/server.git
|
||||
git clone https://github.com/lysand-org/lysand.git
|
||||
```
|
||||
|
||||
3. Install the dependencies
|
||||
2. Install the dependencies
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
1. Set up a PostgreSQL database (you need a special extension, please look at [the database documentation](https://server.versia.pub/setup/database))
|
||||
3. Set up a PostgreSQL database (you need a special extension, please look at [the database documentation](database.md))
|
||||
|
||||
2. Copy the `config/config.example.toml` file to `config/config.toml` and edit it to set up the database connection and other settings.
|
||||
|
||||
## HTTPS development
|
||||
|
||||
To develop with HTTPS, you need to generate a self-signed certificate. We will use [`mkcert`](https://github.com/FiloSottile/mkcert) for this purpose.
|
||||
|
||||
1. Install `mkcert`:
|
||||
2. Generate a certificate for the domain you are using:
|
||||
```sh
|
||||
mkcert -install
|
||||
# You can change the domain to whatever you want, but it must resolve via /etc/hosts
|
||||
# *.localhost domains are automatically aliased to localhost by DNS
|
||||
mkcert -key-file config/versia.localhost-key.pem -cert-file config/versia.localhost.pem versia.localhost
|
||||
```
|
||||
3. Edit the config to use your database and HTTPS certificates, e.g:
|
||||
```toml
|
||||
[http]
|
||||
base_url = "https://versia.localhost:9900"
|
||||
bind = "versia.localhost"
|
||||
bind_port = 9900 # Change the port to whatever you want
|
||||
|
||||
[http.tls]
|
||||
enabled = true
|
||||
key = "config/versia.localhost-key.pem"
|
||||
cert = "config/versia.localhost.pem"
|
||||
passphrase = ""
|
||||
ca = ""
|
||||
```
|
||||
|
||||
Now, running the server will use the certificate you generated.
|
||||
4. Copy the `config/config.toml.example` file to `config/config.toml` and edit it to set up the database connection and other settings.
|
||||
|
||||
## Testing your changes
|
||||
|
||||
|
|
@ -82,12 +54,10 @@ If your port number is lower than 1024, you may need to run the command as root.
|
|||
|
||||
To run the tests, run:
|
||||
```sh
|
||||
bun run test
|
||||
bun test
|
||||
```
|
||||
|
||||
The `bun test` command will cause errors due to Bun bugs ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)). Use the `test` script instead.
|
||||
|
||||
The tests are located all around the codebase (filename `*.test.ts`) and follow a Jest-like syntax. The server should be shut down before running the tests.
|
||||
The tests are located in the `tests/` directory and follow a Jest-like syntax. The server should be shut down before running the tests.
|
||||
|
||||
## Code style
|
||||
|
||||
|
|
@ -99,7 +69,7 @@ bun lint
|
|||
|
||||
To automatically fix the issues, run:
|
||||
```sh
|
||||
bun lint --write
|
||||
bun lint --apply
|
||||
```
|
||||
|
||||
You can also install the Biome Visual Studio Code extension and have it format your code automatically on save.
|
||||
|
|
@ -110,11 +80,6 @@ Linting should not be ignored, except if they are false positives, in which case
|
|||
|
||||
TypeScript errors should be ignored with `// @ts-expect-error` comments, as well as with a reason for being ignored.
|
||||
|
||||
To scan for all TypeScript errors, run:
|
||||
```sh
|
||||
bun typecheck
|
||||
```
|
||||
|
||||
### Commit messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org) for our commit messages. This allows us to automatically generate the changelog and the version number, while also making it easier to understand what changes were made in each commit.
|
||||
|
|
@ -131,11 +96,11 @@ Tests **should** be written for all API routes and all functions that are not tr
|
|||
|
||||
#### Adding per-route tests
|
||||
|
||||
To add tests for a route, create a `route_file_name.test.ts` file in the same directory as the route itself. See [this example](/api/api/v1/timelines/home.test.ts) for help writing tests.
|
||||
To add tests for a route, create a `route_file_name.test.ts` file in the same directory as the route itself. See [this example](/server/api/api/v1/timelines/home.test.ts) for help writing tests.
|
||||
|
||||
### Writing documentation
|
||||
|
||||
Documentation for the Versia protocol is available on [versia.pub](https://versia.pub/). If you are thinking of modifying the protocol, please make sure to send a pull request over there to get it approved and merged before you send your pull request here.
|
||||
Documentation for the Lysand protocol is available on [lysand.org](https://lysand.org/). If you are thinking of modifying the protocol, please make sure to send a pull request over there to get it approved and merged before you send your pull request here.
|
||||
|
||||
This project should not need much documentation, but if you think that something needs to be documented, please add it to the README, docs or contribution guide.
|
||||
|
||||
|
|
@ -146,11 +111,11 @@ If you find a bug, please open an issue on GitHub. Please make sure to include t
|
|||
- The steps to reproduce the bug
|
||||
- The expected behavior
|
||||
- The actual behavior
|
||||
- The version of Versia Server you are using
|
||||
- The version of Lysand you are using
|
||||
- The version of Bun you are using
|
||||
- The version of PostgreSQL you are using
|
||||
- Your operating system and version
|
||||
|
||||
# License
|
||||
|
||||
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
|
||||
Lysand is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Lysand, you agree to license your contributions under the same license.
|
||||
39
Dockerfile
|
|
@ -1,5 +1,8 @@
|
|||
# Node is required for building the project
|
||||
FROM imbios/bun-node:latest-23-alpine AS base
|
||||
# Use 1.1.4 for building to prevent a Unicode bug with 1.1.5+
|
||||
# Temporary until they fix it
|
||||
FROM imbios/bun-node:1.1.4-current-alpine as base
|
||||
|
||||
RUN apk add --no-cache libstdc++
|
||||
|
||||
# Install dependencies into temp directory
|
||||
# This will cache them and speed up future builds
|
||||
|
|
@ -8,44 +11,40 @@ FROM base AS install
|
|||
RUN mkdir -p /temp
|
||||
COPY . /temp
|
||||
WORKDIR /temp
|
||||
RUN bun install --production
|
||||
RUN bun install --frozen-lockfile --production
|
||||
|
||||
FROM base AS build
|
||||
FROM base as build
|
||||
|
||||
# Copy the project
|
||||
RUN mkdir -p /temp
|
||||
COPY . /temp
|
||||
# Copy dependencies
|
||||
COPY --from=install /temp/node_modules /temp/node_modules
|
||||
|
||||
# Build the project
|
||||
WORKDIR /temp
|
||||
RUN bun run build api
|
||||
RUN bun run build
|
||||
WORKDIR /temp/dist
|
||||
|
||||
# Copy production dependencies and source code into final image
|
||||
FROM oven/bun:1.3.2-alpine
|
||||
FROM oven/bun:1.1.9-alpine
|
||||
|
||||
# Install libstdc++ for Bun and create app directory
|
||||
RUN apk add --no-cache libstdc++
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /app
|
||||
|
||||
COPY --from=build /temp/dist /app/dist
|
||||
COPY entrypoint.sh /app
|
||||
|
||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||
LABEL org.opencontainers.image.title="Versia Server"
|
||||
LABEL org.opencontainers.image.description="Versia Server Docker image"
|
||||
|
||||
# Set current Git commit hash as an environment variable
|
||||
ARG GIT_COMMIT
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
LABEL org.opencontainers.image.authors "Gaspard Wierzbinski (https://cpluspatch.dev)"
|
||||
LABEL org.opencontainers.image.source "https://github.com/lysand-org/lysand"
|
||||
LABEL org.opencontainers.image.vendor "Lysand Org"
|
||||
LABEL org.opencontainers.image.licenses "AGPL-3.0-or-later"
|
||||
LABEL org.opencontainers.image.title "Lysand Server"
|
||||
LABEL org.opencontainers.image.description "Lysand Server docker image"
|
||||
|
||||
# CD to app
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||
# Run migrations and start the server
|
||||
CMD [ "bun", "run", "api.js" ]
|
||||
CMD [ "cli", "start" ]
|
||||
|
|
|
|||
98
README.md
|
|
@ -1,52 +1,30 @@
|
|||
<div align="center">
|
||||
<a href="https://versia.pub">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://cdn.versia.pub/branding/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://cdn.versia.pub/branding/logo-light.svg">
|
||||
<img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
<p align="center">
|
||||
<a href="https://lysand.org"><img src="https://cdn.lysand.org/logo-long-dark.webp" alt="Lysand Logo" height="110"></a>
|
||||
</p>
|
||||
|
||||
|
||||
<h2 align="center">
|
||||
<strong><code>Versia Server</code></strong>
|
||||
</h2>
|
||||
|
||||
<div align="center">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/typescript/typescript-original.svg" height="42" width="52" alt="TypeScript logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg" height="42" width="52" alt="PostgreSQL logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/docker/docker-original.svg" height="42" width="52" alt="Docker logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bun/bun-original.svg" height="42" width="52" alt="Bun logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/vscode/vscode-original.svg" height="42" width="52" alt="VSCode logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/sentry/sentry-original.svg" height="42" width="52" alt="Sentry logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/linux/linux-original.svg" height="42" width="52" alt="Linux logo">
|
||||
</div>
|
||||
|
||||
|
||||
<br/>
|
||||
      [](code_of_conduct.md)
|
||||
|
||||
## What is this?
|
||||
|
||||
**Versia Server** (formerly Lysand Server) is a federated social network server based on the [Versia](https://versia.pub) protocol. It is currently in beta phase, with basic federation and almost complete Mastodon API support.
|
||||
**Lysand Server** is a federated social network server based on the [Lysand](https://lysand.org) protocol. It is currently in beta phase, with basic federation and almost complete Mastodon API support.
|
||||
|
||||
### Goals
|
||||
|
||||
- **Privacy**: Versia Server is designed to be as private as possible. Unnecessary data is not stored, and data that is stored is done so securely.
|
||||
- **Configurability**: High configurability is a key feature of Versia Server. Almost every aspect of the server can be configured to suit your needs. If you feel like something is missing, please open an issue.
|
||||
- **Security**: Versia Server is designed with security in mind. It is built with modern security practices and technologies, and is constantly updated to ensure the highest level of security.
|
||||
- **Performance**: Efficiency and speed are a key focus of Versia Server. The design is non-monolithic, and is built to be as fast as possible.
|
||||
- **Mastodon API compatibility**: Versia Server is designed to be compatible with the Mastodon API, with [`glitch-soc`](https://github.com/glitch-soc/mastodon) extensions.
|
||||
- **Privacy**: Lysand is designed to be as private as possible. Unnecessary data is not stored, and data that is stored is done so securely.
|
||||
- **Configurability**: High configurability is a key feature of Lysand. Almost every aspect of the server can be configured to suit your needs. If you feel like something is missing, please open an issue.
|
||||
- **Security**: Lysand is designed with security in mind. It is built with modern security practices and technologies, and is constantly updated to ensure the highest level of security.
|
||||
- **Performance**: Efficiency and speed are a key focus of Lysand. The design is non-monolithic, and is built to be as fast as possible.
|
||||
- **Mastodon API compatibility**: Lysand is designed to be compatible with the Mastodon API, with Glitch-SOC extensions.
|
||||
|
||||
### Anti-Goals
|
||||
|
||||
- **Monolithic design**: Modularity and scaling is important to this project. This means that it is not a single, monolithic application, but rather a collection of smaller, more focused applications. (API layer, queue, database, frontend, etc.)
|
||||
- **Complexity**: Both in code and in function, Versia Server should be as simple as possible. This does not mean adding no features or functionality, but rather that the features and functionality that are added should be well-written and easy to understand.
|
||||
- **Bloat**: Versia Server should not be bloated with unnecessary features, packages, dependencies or code. It should be as lightweight as possible, while still being feature-rich.
|
||||
- **Complexity**: Both in code and in function, Lysand should be as simple as possible. This does not mean adding no features or functionality, but rather that the features and functionality that are added should be well-written and easy to understand.
|
||||
- **Bloat**: Lysand should not be bloated with unnecessary features, packages, dependencies or code. It should be as lightweight as possible, while still being feature-rich.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Versia Working Draft 4 federation (partial)
|
||||
- [x] Fully compliant Lysand 3.0 federation (partial)
|
||||
- [x] Hyper fast (thousands of HTTP requests per second)
|
||||
- [x] S3 or local media storage
|
||||
- [x] Deduplication of uploaded files
|
||||
|
|
@ -58,31 +36,25 @@
|
|||
- [x] Automatic image conversion to WebP or other formats
|
||||
- [x] Scripting-compatible CLI with JSON and CSV outputs
|
||||
- [x] Markdown support just about everywhere: posts, profiles, profile fields, etc. Code blocks, tables, and more are supported.
|
||||
- [ ] Advanced moderation tools (work in progress)
|
||||
- [ ] Moderation tools
|
||||
- [x] Fully compliant Mastodon API support (partial)
|
||||
- [x] Glitch-SOC extensions
|
||||
- [x] Full compatibility with many clients such as Megalodon
|
||||
- [x] Ability to use your own frontends
|
||||
- [x] Non-monolithic architecture, microservices can be hosted in infinite amounts on infinite servers
|
||||
- [x] Ability to use all your threads
|
||||
- [x] Support for SSO providers, as well as SSO-only registration.
|
||||
- [x] Support for SSO providers (and disabling password logins)
|
||||
- [x] Fully written in TypeScript and thoroughly unit tested
|
||||
- [x] Automatic signed container builds for easy deployment
|
||||
- [x] Docker and Podman supported
|
||||
- [x] Invisible, Proof-of-Work local CAPTCHA for API requests
|
||||
- [x] Advanced Roles and Permissions API.
|
||||
- [x] HTTP proxy support
|
||||
- [x] Tor hidden service support
|
||||
- [x] Sentry logging support
|
||||
- [x] Ability to change the domain name in a single config change, without any database edits
|
||||
|
||||
## Screenshots
|
||||
|
||||
You can visit [social.lysand.org](https://social.lysand.org) to see a live instance of Versia Server with Versia-FE.
|
||||
You can visit [social.lysand.org](https://social.lysand.org) to see a live instance of Lysand with Lysand-FE.
|
||||
|
||||
## How do I run it?
|
||||
|
||||
Please see the [installation guide](https://server.versia.pub/setup/installation) for more information on how to install Versia.
|
||||
Please see the [installation guide](docs/installation.md) for more information on how to install Lysand.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
@ -91,15 +63,13 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil
|
|||
## Federation
|
||||
|
||||
The following extensions are currently supported or being worked on:
|
||||
- `pub.versia:custom_emojis`: Custom emojis
|
||||
- `pub.versia:instance_messaging`: Instance Messaging
|
||||
- `pub.versia:likes`: Likes
|
||||
- `pub.versia:share`: Share
|
||||
- `pub.versia:reactions`: Reactions
|
||||
- `org.lysand:custom_emojis`: Custom emojis
|
||||
- `org.lysand:polls`: Polls
|
||||
- `org.lysand:microblogging`: Microblogging
|
||||
|
||||
## API
|
||||
|
||||
Versia Server implements the Mastodon API (as well as `glitch-soc` extensions). The API is currently almost fully complete, with some fringe functionality still being worked on.
|
||||
Lysand implements the Mastodon API (as well as Glitch-Soc extensions). The API is currently almost fully complete, with some fringe functionality still being worked on.
|
||||
|
||||
Working endpoints are:
|
||||
|
||||
|
|
@ -211,8 +181,8 @@ Working endpoints are:
|
|||
- [ ] `/api/v2/suggestions`
|
||||
- [x] `/oauth/authorize`
|
||||
- [x] `/oauth/token`
|
||||
- [x] `/oauth/revoke`
|
||||
- Admin API
|
||||
- [ ] `/oauth/revoke`
|
||||
- Admin API
|
||||
|
||||
### Main work to do for API
|
||||
|
||||
|
|
@ -230,28 +200,10 @@ Working endpoints are:
|
|||
- [ ] Reports
|
||||
- [ ] Admin API
|
||||
|
||||
## Versia Server API
|
||||
## Lysand API
|
||||
|
||||
For Versia Server's own custom API, please see the [API documentation](https://server.versia.pub/api/emojis).
|
||||
For Lysand's own custom API, please see the [API documentation](docs/api/index.md).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [AGPL-3.0-or-later](LICENSE).
|
||||
|
||||
All Versia assets (icon, logo, banners, etc) are licensed under [CC-BY-NC-SA-4.0](https://creativecommons.org/licenses/by-nc-sa/4.0)
|
||||
|
||||
## Thanks!
|
||||
|
||||
Thanks to [**Fastly**](https://fastly.com) for providing us with support and resources to build Versia!
|
||||
|
||||
<br />
|
||||
|
||||
<p align="center">
|
||||
<a href="https://fastly.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/fastly-red.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/fastly-red.svg">
|
||||
<img src="assets/fastly-red.svg" alt="Fastly Logo" height="110" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
|
|
|||
22
SECURITY.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
For now, only the released latest version of Lysand is supported for security updates. This will change as Lysand exits alpha status.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you find a vulnerability, please report it to [@CPlusPatch](https://github.com/CPlusPatch) at the following contact endpoints:
|
||||
|
||||
- [Matrix](https://matrix.to/#/@jesse:cpluspatch.dev)
|
||||
- [E-mail](mailto:contact@cpluspatch.com)
|
||||
|
||||
Please do not report vulnerabilities publicly until they have been patched. If you would like to be credited for your discovery, please include your name and/or GitHub username in your report.
|
||||
|
||||
## Vulnerability Disclosure Policy
|
||||
|
||||
Lysand is an open-source project, and as such, we welcome security researchers to audit our code and report vulnerabilities. We will do our best to patch vulnerabilities as quickly as possible, and will credit researchers for their discoveries if they wish to be credited.
|
||||
|
||||
For security reasons, we ask that you do not publicly disclose vulnerabilities until they have been patched. We will do our best to patch vulnerabilities as quickly as possible, and will credit researchers for their discoveries if they wish to be credited.
|
||||
|
||||
Thank you for helping to keep Lysand secure! :3
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# Node is required for building the project
|
||||
FROM imbios/bun-node:latest-23-alpine AS base
|
||||
|
||||
# Install dependencies into temp directory
|
||||
# This will cache them and speed up future builds
|
||||
FROM base AS install
|
||||
|
||||
RUN mkdir -p /temp
|
||||
COPY . /temp
|
||||
WORKDIR /temp
|
||||
RUN bun install --production
|
||||
|
||||
FROM base AS build
|
||||
|
||||
# Copy the project
|
||||
RUN mkdir -p /temp
|
||||
COPY . /temp
|
||||
# Copy dependencies
|
||||
COPY --from=install /temp/node_modules /temp/node_modules
|
||||
|
||||
# Build the project
|
||||
WORKDIR /temp
|
||||
RUN bun run build worker
|
||||
WORKDIR /temp/dist
|
||||
|
||||
# Copy production dependencies and source code into final image
|
||||
FROM oven/bun:1.3.2-alpine
|
||||
|
||||
# Install libstdc++ for Bun and create app directory
|
||||
RUN mkdir -p /app
|
||||
|
||||
COPY --from=build /temp/dist /app/dist
|
||||
COPY entrypoint.sh /app
|
||||
|
||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||
LABEL org.opencontainers.image.title="Versia Server Worker"
|
||||
LABEL org.opencontainers.image.description="Versia Server Worker Docker image"
|
||||
|
||||
# Set current Git commit hash as an environment variable
|
||||
ARG GIT_COMMIT
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
|
||||
# CD to app
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||
# Run migrations and start the server
|
||||
CMD [ "bun", "run", "worker.js" ]
|
||||
19
api.ts
|
|
@ -1,19 +0,0 @@
|
|||
import process from "node:process";
|
||||
import { appFactory } from "@versia-server/api";
|
||||
import { config } from "@versia-server/config";
|
||||
import { Youch } from "youch";
|
||||
import { createServer } from "@/server.ts";
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
process.exit();
|
||||
});
|
||||
|
||||
process.on("uncaughtException", async (error) => {
|
||||
const youch = new Youch();
|
||||
|
||||
console.error(await youch.toANSI(error));
|
||||
});
|
||||
|
||||
await import("@versia-server/api/setup");
|
||||
|
||||
createServer(config, await appFactory());
|
||||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 198.27"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Fastly_Logo_-_Red" data-name="Fastly Logo - Red"><g id="Fastly_Logo_-_Red-2" data-name="Fastly Logo - Red"><polygon points="348.44 20.35 348.44 153.94 388.57 153.94 388.57 133.53 375.31 133.53 375.31 0 348.44 0 348.44 20.35"/><path d="M0,133.53H13.64V69.08H0V51.35l13.64-2.24V31.17C13.64,9.43,18.37,0,46.09,0A115.17,115.17,0,0,1,65.38,2L61.7,23.85a49.78,49.78,0,0,0-9-.78c-9.76,0-12.23,1-12.23,10.51V49.11H60.79v20H40.51v64.45H54v20.4H0Z"/><path d="M334.78,127.08a53.11,53.11,0,0,1-10.54.84c-11.06.27-10.1-3.36-10.1-13.78V69.08h21v-20h-21V0H287.27V119.71c0,23.5,5.8,34.23,31.08,34.23,6,0,14.21-1.54,20.42-2.87Z"/><path d="M501.7,133.63a10.14,10.14,0,1,1-10.19,10.14,10.14,10.14,0,0,1,10.19-10.14m0,18.68a8.55,8.55,0,0,0,8.51-8.54,8.5,8.5,0,1,0-8.51,8.54m1.88-3.56-2.05-3h-1.42v3h-2.29v-10H502c2.46,0,4,1.24,4,3.45a3,3,0,0,1-2.08,3.09l2.49,3.42Zm-3.47-5h1.82c1,0,1.74-.4,1.74-1.5s-.7-1.45-1.68-1.45h-1.88Z"/><path d="M253.72,69V65.46A115.8,115.8,0,0,0,233.14,64c-12.5,0-14,6.63-14,10.23,0,5.08,1.74,7.83,15.29,10.79,19.8,4.45,39.69,9.09,39.69,33.64,0,23.29-12,35.32-37.21,35.32-16.88,0-33.26-3.63-45.76-6.8V127.08h20.35v3.56c8.75,1.69,17.93,1.52,22.73,1.52,13.34,0,15.49-7.17,15.49-11,0-5.29-3.82-7.83-16.32-10.37-23.56-4-42.25-12.07-42.25-36,0-22.65,15.14-31.54,40.37-31.54,17.09,0,30.08,2.65,42.59,5.83V69Z"/><path d="M127.84,85.09,118,93.69a5.25,5.25,0,1,0,3.19,3.2l8.72-9.75Z"/><path d="M171.25,127.07V43.46H144.37V51a55,55,0,0,0-18.11-6.77v-9.1h3.28V28.28H102.48v6.83h3.28v9.17a55.32,55.32,0,1,0,38.76,101.87l4.77,7.78h28.38V127.07Zm-26.64-26.83A28.42,28.42,0,0,1,117.73,127v-3.18h-3.22V127a28.43,28.43,0,0,1-26.68-26.89H91V96.91H87.85a28.42,28.42,0,0,1,26.66-26.65v3.16h3.22V70.25A28.42,28.42,0,0,1,144.61,97h-3.2v3.22Z"/><path d="M456.58,49.11H512v20H498.75l-34,83.62c-9.74,23.48-25.74,45.59-50.1,45.59a93.67,93.67,0,0,1-19.5-2l2.43-24.39a68.7,68.7,0,0,0,10.63,1.1c11.3,0,24-7,28-19.19L401.82,69.06H388.57v-20H444v20H430.78l19.51,48h0l19.51-48H456.58Z"/></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 198.27"><defs><style>.cls-1{fill:#ff282d;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Fastly_Logo_-_Red" data-name="Fastly Logo - Red"><g id="Fastly_Logo_-_Red-2" data-name="Fastly Logo - Red"><polygon class="cls-1" points="348.44 20.35 348.44 153.94 388.57 153.94 388.57 133.53 375.31 133.53 375.31 0 348.44 0 348.44 20.35"/><path class="cls-1" d="M0,133.53H13.64V69.08H0V51.35l13.64-2.24V31.17C13.64,9.43,18.37,0,46.09,0A115.17,115.17,0,0,1,65.38,2L61.7,23.85a49.78,49.78,0,0,0-9-.78c-9.76,0-12.23,1-12.23,10.51V49.11H60.79v20H40.51v64.45H54v20.4H0Z"/><path class="cls-1" d="M334.78,127.08a53.11,53.11,0,0,1-10.54.84c-11.06.27-10.1-3.36-10.1-13.78V69.08h21v-20h-21V0H287.27V119.71c0,23.5,5.8,34.23,31.08,34.23,6,0,14.21-1.54,20.42-2.87Z"/><path class="cls-1" d="M501.7,133.63a10.14,10.14,0,1,1-10.19,10.14,10.14,10.14,0,0,1,10.19-10.14m0,18.68a8.55,8.55,0,0,0,8.51-8.54,8.5,8.5,0,1,0-8.51,8.54m1.88-3.56-2.05-3h-1.42v3h-2.29v-10H502c2.46,0,4,1.24,4,3.45a3,3,0,0,1-2.08,3.09l2.49,3.42Zm-3.47-5h1.82c1,0,1.74-.4,1.74-1.5s-.7-1.45-1.68-1.45h-1.88Z"/><path class="cls-1" d="M253.72,69V65.46A115.8,115.8,0,0,0,233.14,64c-12.5,0-14,6.63-14,10.23,0,5.08,1.74,7.83,15.29,10.79,19.8,4.45,39.69,9.09,39.69,33.64,0,23.29-12,35.32-37.21,35.32-16.88,0-33.26-3.63-45.76-6.8V127.08h20.35v3.56c8.75,1.69,17.93,1.52,22.73,1.52,13.34,0,15.49-7.17,15.49-11,0-5.29-3.82-7.83-16.32-10.37-23.56-4-42.25-12.07-42.25-36,0-22.65,15.14-31.54,40.37-31.54,17.09,0,30.08,2.65,42.59,5.83V69Z"/><path class="cls-1" d="M127.84,85.09,118,93.69a5.25,5.25,0,1,0,3.19,3.2l8.72-9.75Z"/><path class="cls-1" d="M171.25,127.07V43.46H144.37V51a55,55,0,0,0-18.11-6.77v-9.1h3.28V28.28H102.48v6.83h3.28v9.17a55.32,55.32,0,1,0,38.76,101.87l4.77,7.78h28.38V127.07Zm-26.64-26.83A28.42,28.42,0,0,1,117.73,127v-3.18h-3.22V127a28.43,28.43,0,0,1-26.68-26.89H91V96.91H87.85a28.42,28.42,0,0,1,26.66-26.65v3.16h3.22V70.25A28.42,28.42,0,0,1,144.61,97h-3.2v3.22Z"/><path class="cls-1" d="M456.58,49.11H512v20H498.75l-34,83.62c-9.74,23.48-25.74,45.59-50.1,45.59a93.67,93.67,0,0,1-19.5-2l2.43-24.39a68.7,68.7,0,0,0,10.63,1.1c11.3,0,24-7,28-19.19L401.82,69.06H388.57v-20H444v20H430.78l19.51,48h0l19.51-48H456.58Z"/></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 198.27"><defs><style>.cls-1{fill:#fff;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Fastly_Logo_-_Red" data-name="Fastly Logo - Red"><g id="Fastly_Logo_-_Red-2" data-name="Fastly Logo - Red"><polygon class="cls-1" points="348.44 20.35 348.44 153.94 388.57 153.94 388.57 133.53 375.31 133.53 375.31 0 348.44 0 348.44 20.35"/><path class="cls-1" d="M0,133.53H13.64V69.08H0V51.35l13.64-2.24V31.17C13.64,9.43,18.37,0,46.09,0A115.17,115.17,0,0,1,65.38,2L61.7,23.85a49.78,49.78,0,0,0-9-.78c-9.76,0-12.23,1-12.23,10.51V49.11H60.79v20H40.51v64.45H54v20.4H0Z"/><path class="cls-1" d="M334.78,127.08a53.11,53.11,0,0,1-10.54.84c-11.06.27-10.1-3.36-10.1-13.78V69.08h21v-20h-21V0H287.27V119.71c0,23.5,5.8,34.23,31.08,34.23,6,0,14.21-1.54,20.42-2.87Z"/><path class="cls-1" d="M501.7,133.63a10.14,10.14,0,1,1-10.19,10.14,10.14,10.14,0,0,1,10.19-10.14m0,18.68a8.55,8.55,0,0,0,8.51-8.54,8.5,8.5,0,1,0-8.51,8.54m1.88-3.56-2.05-3h-1.42v3h-2.29v-10H502c2.46,0,4,1.24,4,3.45a3,3,0,0,1-2.08,3.09l2.49,3.42Zm-3.47-5h1.82c1,0,1.74-.4,1.74-1.5s-.7-1.45-1.68-1.45h-1.88Z"/><path class="cls-1" d="M253.72,69V65.46A115.8,115.8,0,0,0,233.14,64c-12.5,0-14,6.63-14,10.23,0,5.08,1.74,7.83,15.29,10.79,19.8,4.45,39.69,9.09,39.69,33.64,0,23.29-12,35.32-37.21,35.32-16.88,0-33.26-3.63-45.76-6.8V127.08h20.35v3.56c8.75,1.69,17.93,1.52,22.73,1.52,13.34,0,15.49-7.17,15.49-11,0-5.29-3.82-7.83-16.32-10.37-23.56-4-42.25-12.07-42.25-36,0-22.65,15.14-31.54,40.37-31.54,17.09,0,30.08,2.65,42.59,5.83V69Z"/><path class="cls-1" d="M127.84,85.09,118,93.69a5.25,5.25,0,1,0,3.19,3.2l8.72-9.75Z"/><path class="cls-1" d="M171.25,127.07V43.46H144.37V51a55,55,0,0,0-18.11-6.77v-9.1h3.28V28.28H102.48v6.83h3.28v9.17a55.32,55.32,0,1,0,38.76,101.87l4.77,7.78h28.38V127.07Zm-26.64-26.83A28.42,28.42,0,0,1,117.73,127v-3.18h-3.22V127a28.43,28.43,0,0,1-26.68-26.89H91V96.91H87.85a28.42,28.42,0,0,1,26.66-26.65v3.16h3.22V70.25A28.42,28.42,0,0,1,144.61,97h-3.2v3.22Z"/><path class="cls-1" d="M456.58,49.11H512v20H498.75l-34,83.62c-9.74,23.48-25.74,45.59-50.1,45.59a93.67,93.67,0,0,1-19.5-2l2.43-24.39a68.7,68.7,0,0,0,10.63,1.1c11.3,0,24-7,28-19.19L401.82,69.06H388.57v-20H444v20H430.78l19.51,48h0l19.51-48H456.58Z"/></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
BIN
assets/login.webp
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
assets/login_mobile.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/main.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/redirect.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/redirect_mobile.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
assets/register.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/register_mobile.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/soapbox.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
|
|
@ -1,46 +0,0 @@
|
|||
import type { Status } from "@versia/client/schemas";
|
||||
import {
|
||||
fakeRequest,
|
||||
getTestStatuses,
|
||||
getTestUsers,
|
||||
} from "@versia-server/tests";
|
||||
import { bench, run } from "mitata";
|
||||
import type { z } from "zod";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
await getTestStatuses(40, users[0]);
|
||||
|
||||
const testTimeline = async (): Promise<void> => {
|
||||
const response = await fakeRequest("/api/v1/timelines/home", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const objects = (await response.json()) as z.infer<typeof Status>[];
|
||||
|
||||
if (objects.length !== 20) {
|
||||
throw new Error("Invalid response (not 20 objects)");
|
||||
}
|
||||
};
|
||||
|
||||
const testInstance = async (): Promise<void> => {
|
||||
const response = await fakeRequest("/api/v2/instance", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const object = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
if (typeof object !== "object") {
|
||||
throw new Error("Invalid response (not an object)");
|
||||
}
|
||||
};
|
||||
|
||||
bench("timeline", testTimeline).range("amount", 1, 1000);
|
||||
bench("instance", testInstance).range("amount", 1, 1000);
|
||||
|
||||
await run();
|
||||
|
||||
await deleteUsers();
|
||||
174
biome.json
|
|
@ -1,178 +1,20 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.4/schema.json",
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vcs": {
|
||||
"clientKind": "git",
|
||||
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true,
|
||||
"useIgnoreFile": true
|
||||
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"style": {
|
||||
"useNamingConvention": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"requireAscii": false,
|
||||
"strictCase": false,
|
||||
"conventions": [
|
||||
{
|
||||
"selector": {
|
||||
"kind": "typeProperty"
|
||||
},
|
||||
"formats": [
|
||||
"camelCase",
|
||||
"CONSTANT_CASE",
|
||||
"PascalCase",
|
||||
"snake_case"
|
||||
]
|
||||
},
|
||||
{
|
||||
"selector": {
|
||||
"kind": "objectLiteralProperty",
|
||||
"scope": "any"
|
||||
},
|
||||
"formats": [
|
||||
"camelCase",
|
||||
"CONSTANT_CASE",
|
||||
"PascalCase",
|
||||
"snake_case"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"useLiteralEnumMembers": "error",
|
||||
"noNegationElse": "error",
|
||||
"noYodaExpression": "error",
|
||||
"useBlockStatements": "error",
|
||||
"useCollapsedElseIf": "error",
|
||||
"useConsistentArrayType": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"syntax": "shorthand"
|
||||
}
|
||||
},
|
||||
"useConsistentBuiltinInstantiation": "error",
|
||||
"useExplicitLengthCheck": "error",
|
||||
"useForOf": "error",
|
||||
"useNodeAssertStrict": "error",
|
||||
"useShorthandAssign": "error",
|
||||
"useThrowNewError": "error",
|
||||
"useThrowOnlyError": "error",
|
||||
"useNodejsImportProtocol": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useConst": "error",
|
||||
"useSingleVarDeclarator": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"useAtIndex": "warn",
|
||||
"noInferrableTypes": "error",
|
||||
"useCollapsedIf": "warn",
|
||||
"useExponentiationOperator": "error",
|
||||
"useTemplate": "error",
|
||||
"noParameterAssign": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useConsistentMemberAccessibility": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"accessibility": "explicit"
|
||||
}
|
||||
},
|
||||
"useImportType": "error",
|
||||
"useExportType": "error",
|
||||
"noUselessElse": "error",
|
||||
"noProcessEnv": "error",
|
||||
"useShorthandFunctionType": "error",
|
||||
"useArrayLiterals": "error",
|
||||
"noCommonJs": "warn",
|
||||
"noExportedImports": "warn",
|
||||
"noSubstr": "warn",
|
||||
"useTrimStartEnd": "warn",
|
||||
"noRestrictedImports": {
|
||||
"options": {
|
||||
"paths": {
|
||||
"~/packages/": "Use the appropriate package instead of importing from the packages directory directly."
|
||||
}
|
||||
},
|
||||
"level": "error"
|
||||
}
|
||||
},
|
||||
"performance": {
|
||||
"noDynamicNamespaceImportAccess": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"useImportExtensions": "error",
|
||||
"noConstantMathMinMaxClamp": "error",
|
||||
"noUndeclaredDependencies": "error",
|
||||
"noUnusedFunctionParameters": "error",
|
||||
"noUnusedImports": "error",
|
||||
"noUnusedPrivateClassMembers": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"noFloatingPromises": "error"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "error",
|
||||
"noImportantStyles": "off",
|
||||
"noUselessStringConcat": "error",
|
||||
"useDateNow": "error",
|
||||
"noUselessStringRaw": "warn",
|
||||
"noUselessEscapeInRegex": "warn",
|
||||
"useSimplifiedLogicExpression": "error",
|
||||
"useWhile": "error",
|
||||
"useNumericLiterals": "error",
|
||||
"noArguments": "error",
|
||||
"noCommaOperator": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDuplicateTestHooks": "error",
|
||||
"noOctalEscape": "error",
|
||||
"noTemplateCurlyInString": "warn",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"useAdjacentOverloadSignatures": "warn",
|
||||
"useGuardForIn": "warn",
|
||||
"noDuplicateElseIf": "warn",
|
||||
"noEvolvingTypes": "error",
|
||||
"noIrregularWhitespace": "warn",
|
||||
"noExportsInTest": "error",
|
||||
"noVar": "error",
|
||||
"useAwait": "error",
|
||||
"useErrorMessage": "error",
|
||||
"useNumberToFixedDigitsArgument": "error"
|
||||
}
|
||||
}
|
||||
"recommended": true
|
||||
},
|
||||
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/packages/client/versia/client.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"useNamingConvention": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4
|
||||
},
|
||||
"javascript": {
|
||||
"globals": ["HTMLRewriter", "BufferEncoding"]
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**"]
|
||||
"indentWidth": 4,
|
||||
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
build.ts
|
|
@ -1,55 +1,64 @@
|
|||
import process from "node:process";
|
||||
import { $, build, file, write } from "bun";
|
||||
import manifest from "./package.json" with { type: "json" };
|
||||
import { $ } from "bun";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { routes } from "~routes";
|
||||
|
||||
console.log("Building...");
|
||||
const buildSpinner = ora("Building").start();
|
||||
|
||||
await $`rm -rf dist && mkdir dist`;
|
||||
|
||||
const type = process.argv[2] as "api" | "worker";
|
||||
|
||||
if (type !== "api" && type !== "worker") {
|
||||
throw new Error("Invalid build type. Use 'api' or 'worker'.");
|
||||
}
|
||||
|
||||
const packages = Object.keys(manifest.dependencies)
|
||||
.filter((dep) => dep.startsWith("@versia"))
|
||||
.filter((dep) => dep !== "@versia-server/tests");
|
||||
|
||||
await build({
|
||||
entrypoints: [`./${type}.ts`],
|
||||
await Bun.build({
|
||||
entrypoints: [
|
||||
"index.ts",
|
||||
"cli/index.ts",
|
||||
// Force Bun to include endpoints
|
||||
...Object.values(routes),
|
||||
],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
external: [...packages],
|
||||
minify: false,
|
||||
external: ["bullmq", "unzipit"],
|
||||
}).then((output) => {
|
||||
if (!output.success) {
|
||||
console.log(output.logs);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Copying files...");
|
||||
buildSpinner.text = "Transforming";
|
||||
|
||||
// Copy each package into dist/node_modules
|
||||
for (const pkg of packages) {
|
||||
const directory = pkg.split("/")[1] || pkg;
|
||||
await $`mkdir -p dist/node_modules/${pkg}`;
|
||||
// Copy the built package files
|
||||
await $`cp -rL packages/${directory}/{dist,package.json} dist/node_modules/${pkg}`;
|
||||
// Fix for wrong Bun file resolution, replaces node_modules with ./node_modules inside all dynamic imports
|
||||
// I apologize for this
|
||||
await $`sed -i 's|import("node_modules/|import("./node_modules/|g' dist/*.js`;
|
||||
await $`sed -i 's|import"node_modules/|import"./node_modules/|g' dist/**/*.js`;
|
||||
// Replace /temp/node_modules with ./node_modules
|
||||
await $`sed -i 's|/temp/node_modules|./node_modules|g' dist/**/*.js`;
|
||||
|
||||
// Rewrite package.json "exports" field to point to the dist directory and use .js extension
|
||||
const packageJsonPath = `dist/node_modules/${pkg}/package.json`;
|
||||
const packageJson = await file(packageJsonPath).json();
|
||||
for (const [key, value] of Object.entries(packageJson.exports) as [
|
||||
string,
|
||||
{ import?: string },
|
||||
][]) {
|
||||
if (value.import) {
|
||||
packageJson.exports[key] = {
|
||||
import: value.import
|
||||
.replace("./", "./dist/")
|
||||
.replace(/\.ts$/, ".js"),
|
||||
};
|
||||
}
|
||||
}
|
||||
await write(packageJsonPath, JSON.stringify(packageJson, null, 4));
|
||||
}
|
||||
// Copy Drizzle migrations to dist
|
||||
await $`cp -r drizzle dist/drizzle`;
|
||||
|
||||
console.log("Build complete!");
|
||||
// Copy Sharp to dist
|
||||
await $`mkdir -p dist/node_modules/@img`;
|
||||
await $`cp -r node_modules/@img/sharp-libvips-linux-* dist/node_modules/@img`;
|
||||
await $`cp -r node_modules/@img/sharp-linux-* dist/node_modules/@img`;
|
||||
|
||||
// Copy unzipit and uzip-module to dist
|
||||
await $`cp -r node_modules/unzipit dist/node_modules/unzipit`;
|
||||
await $`cp -r node_modules/uzip-module dist/node_modules/uzip-module`;
|
||||
|
||||
// Copy the Bee Movie script from pages
|
||||
await $`cp beemovie.txt dist/beemovie.txt`;
|
||||
|
||||
// Copy package.json
|
||||
await $`cp package.json dist/package.json`;
|
||||
// Copy cli/theme.json
|
||||
await $`cp cli/theme.json dist/cli/theme.json`;
|
||||
|
||||
buildSpinner.stop();
|
||||
|
||||
console.log(
|
||||
`${chalk.green("✓")} Built project. You can now run it with ${chalk.green(
|
||||
"bun run dist/index.js",
|
||||
)}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,2 @@
|
|||
[install.scopes]
|
||||
"@jsr" = "https://npm.jsr.io"
|
||||
|
||||
[test]
|
||||
preload = ["./packages/tests/setup.ts"]
|
||||
|
||||
[install]
|
||||
linker = "hoisted"
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
/**
|
||||
* @packageDocumentation
|
||||
* @module MediaManager/Utils
|
||||
*/
|
||||
|
||||
import { SHA256 } from "bun";
|
||||
|
||||
/**
|
||||
* Generates a SHA-256 hash for a given file.
|
||||
* @param file - The file to hash.
|
||||
* @returns A promise that resolves to the SHA-256 hash of the file in hex format.
|
||||
*/
|
||||
export const getMediaHash = async (file: File): Promise<string> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const hash = new SHA256().update(arrayBuffer).digest("hex");
|
||||
return hash;
|
||||
};
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { mockModule } from "@versia-server/tests";
|
||||
import sharp from "sharp";
|
||||
import { calculateBlurhash } from "./blurhash.ts";
|
||||
|
||||
describe("BlurhashPreprocessor", () => {
|
||||
it("should calculate blurhash for a valid image", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const result = await calculateBlurhash(inputFile);
|
||||
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result).not.toBe("");
|
||||
});
|
||||
|
||||
it("should return null blurhash for an invalid image", async () => {
|
||||
const invalidFile = new File(["invalid image data"], "invalid.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const result = await calculateBlurhash(invalidFile);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle errors during blurhash calculation", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
using __ = await mockModule("blurhash", () => ({
|
||||
encode: (): void => {
|
||||
throw new Error("Test error");
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await calculateBlurhash(inputFile);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { encode } from "blurhash";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const calculateBlurhash = async (file: File): Promise<string | null> => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const metadata = await sharp(arrayBuffer).metadata();
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
sharp(arrayBuffer)
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.toBuffer((err, buffer) => {
|
||||
if (err) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(
|
||||
encode(
|
||||
new Uint8ClampedArray(buffer),
|
||||
metadata?.width ?? 0,
|
||||
metadata?.height ?? 0,
|
||||
4,
|
||||
4,
|
||||
) as string,
|
||||
);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import sharp from "sharp";
|
||||
import { convertImage } from "./image-conversion.ts";
|
||||
|
||||
describe("ImageConversionPreprocessor", () => {
|
||||
it("should convert a JPEG image to WebP", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.jpg", {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result.type).toBe("image/webp");
|
||||
expect(result.name).toBe("test.webp");
|
||||
|
||||
const resultBuffer = await result.arrayBuffer();
|
||||
const metadata = await sharp(resultBuffer).metadata();
|
||||
expect(metadata.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("should not convert SVG when convert_vector is false", async () => {
|
||||
const svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
|
||||
const inputFile = new File([svgContent], "test.svg", {
|
||||
type: "image/svg+xml",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result).toBe(inputFile);
|
||||
});
|
||||
|
||||
it("should convert SVG when convert_vector is true", async () => {
|
||||
const svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
|
||||
const inputFile = new File([svgContent], "test.svg", {
|
||||
type: "image/svg+xml",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp", {
|
||||
convertVectors: true,
|
||||
});
|
||||
|
||||
expect(result.type).toBe("image/webp");
|
||||
expect(result.name).toBe("test.webp");
|
||||
});
|
||||
|
||||
it("should not convert unsupported file types", async () => {
|
||||
const inputFile = new File(["test content"], "test.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result).toBe(inputFile);
|
||||
});
|
||||
|
||||
it("should throw an error for unsupported output format", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
await expect(convertImage(inputFile, "image/bmp")).rejects.toThrow(
|
||||
"Unsupported output format: image/bmp",
|
||||
);
|
||||
});
|
||||
|
||||
it("should convert animated GIF to WebP while preserving animation", async () => {
|
||||
// Create a simple animated GIF
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 0, b: 0, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.gif()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "animated.gif", {
|
||||
type: "image/gif",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result.type).toBe("image/webp");
|
||||
expect(result.name).toBe("animated.webp");
|
||||
|
||||
const resultBuffer = await result.arrayBuffer();
|
||||
const metadata = await sharp(resultBuffer).metadata();
|
||||
expect(metadata.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("should handle files with spaces in the name", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File(
|
||||
[inputBuffer as BlobPart],
|
||||
"test image with spaces.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result.type).toBe("image/webp");
|
||||
expect(result.name).toBe("test image with spaces.webp");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
/**
|
||||
* @packageDocumentation
|
||||
* @module MediaManager/Preprocessors
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* Supported input media formats.
|
||||
*/
|
||||
const supportedInputFormats = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
/**
|
||||
* Supported output media formats.
|
||||
*/
|
||||
const supportedOutputFormats = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks if a file is convertible.
|
||||
* @param file - The file to check.
|
||||
* @returns True if the file is convertible, false otherwise.
|
||||
*/
|
||||
const isConvertible = (
|
||||
file: File,
|
||||
options?: { convertVectors?: boolean },
|
||||
): boolean => {
|
||||
if (file.type === "image/svg+xml" && !options?.convertVectors) {
|
||||
return false;
|
||||
}
|
||||
return supportedInputFormats.includes(file.type);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the filename from a path.
|
||||
* @param path - The path to extract the filename from.
|
||||
* @returns The extracted filename.
|
||||
*/
|
||||
const extractFilenameFromPath = (path: string): string => {
|
||||
const pathParts = path.split(/(?<!\\)\//);
|
||||
return pathParts.at(-1) as string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the file extension in the filename.
|
||||
* @param fileName - The original filename.
|
||||
* @param newExtension - The new extension.
|
||||
* @returns The filename with the new extension.
|
||||
*/
|
||||
const getReplacedFileName = (fileName: string, newExtension: string): string =>
|
||||
extractFilenameFromPath(fileName).replace(/\.[^/.]+$/, `.${newExtension}`);
|
||||
|
||||
/**
|
||||
* Converts an image file to the format specified in the configuration.
|
||||
*
|
||||
* @param file - The image file to convert.
|
||||
* @param targetFormat - The target format to convert to.
|
||||
* @returns The converted image file.
|
||||
*/
|
||||
export const convertImage = async (
|
||||
file: File,
|
||||
targetFormat: string,
|
||||
options?: {
|
||||
convertVectors?: boolean;
|
||||
},
|
||||
): Promise<File> => {
|
||||
if (!isConvertible(file, options)) {
|
||||
return file;
|
||||
}
|
||||
|
||||
if (!supportedOutputFormats.includes(targetFormat)) {
|
||||
throw new Error(`Unsupported output format: ${targetFormat}`);
|
||||
}
|
||||
|
||||
const sharpCommand = sharp(await file.arrayBuffer(), {
|
||||
animated: true,
|
||||
});
|
||||
const commandName = targetFormat.split("/")[1] as
|
||||
| "jpeg"
|
||||
| "png"
|
||||
| "webp"
|
||||
| "avif"
|
||||
| "gif"
|
||||
| "tiff";
|
||||
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
|
||||
|
||||
return new File(
|
||||
[convertedBuffer as BlobPart],
|
||||
getReplacedFileName(file.name, commandName),
|
||||
{
|
||||
type: targetFormat,
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
);
|
||||
};
|
||||
11
cli/base.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { consoleLogger } from "@loggers";
|
||||
import { Command } from "@oclif/core";
|
||||
import { setupDatabase } from "~drizzle/db";
|
||||
|
||||
export abstract class BaseCommand<T extends typeof Command> extends Command {
|
||||
protected async init(): Promise<void> {
|
||||
await super.init();
|
||||
|
||||
await setupDatabase(consoleLogger, false);
|
||||
}
|
||||
}
|
||||
3
cli/bin/dev.cmd
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
|
||||
bun "%~dp0\dev" %*
|
||||
5
cli/bin/dev.ts
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env -S bun
|
||||
|
||||
import { execute } from "@oclif/core";
|
||||
|
||||
await execute({ development: true, dir: import.meta.url });
|
||||
3
cli/bin/run.cmd
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
|
||||
bun "%~dp0\run" %*
|
||||
5
cli/bin/run.ts
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { execute } from "@oclif/core";
|
||||
|
||||
await execute({ dir: import.meta.url });
|
||||
195
cli/classes.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { Args, type Command, Flags, type Interfaces } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import { and, eq, getTableColumns, like } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis, Instances, Users } from "~drizzle/schema";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import { BaseCommand } from "./base";
|
||||
|
||||
export type FlagsType<T extends typeof Command> = Interfaces.InferredFlags<
|
||||
(typeof BaseCommand)["baseFlags"] & T["flags"]
|
||||
>;
|
||||
export type ArgsType<T extends typeof Command> = Interfaces.InferredArgs<
|
||||
T["args"]
|
||||
>;
|
||||
|
||||
export abstract class UserFinderCommand<
|
||||
T extends typeof BaseCommand,
|
||||
> extends BaseCommand<typeof UserFinderCommand> {
|
||||
static baseFlags = {
|
||||
pattern: Flags.boolean({
|
||||
char: "p",
|
||||
description:
|
||||
"Process as a wildcard pattern (don't forget to escape)",
|
||||
}),
|
||||
type: Flags.string({
|
||||
char: "t",
|
||||
description: "Type of identifier",
|
||||
options: ["id", "username", "note", "display-name", "email"],
|
||||
default: "id",
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of users",
|
||||
default: 100,
|
||||
}),
|
||||
print: Flags.boolean({
|
||||
allowNo: true,
|
||||
default: true,
|
||||
char: "P",
|
||||
description: "Print user(s) found before processing",
|
||||
}),
|
||||
};
|
||||
|
||||
static baseArgs = {
|
||||
identifier: Args.string({
|
||||
description:
|
||||
"Identifier of the user (by default this must be an ID)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
protected flags!: FlagsType<T>;
|
||||
protected args!: ArgsType<T>;
|
||||
|
||||
public async init(): Promise<void> {
|
||||
await super.init();
|
||||
const { args, flags } = await this.parse({
|
||||
flags: this.ctor.flags,
|
||||
baseFlags: (super.ctor as typeof BaseCommand).baseFlags,
|
||||
args: this.ctor.args,
|
||||
strict: this.ctor.strict,
|
||||
});
|
||||
this.flags = flags as FlagsType<T>;
|
||||
this.args = args as ArgsType<T>;
|
||||
}
|
||||
|
||||
public async findUsers(): Promise<User[]> {
|
||||
// Check if there are asterisks in the identifier but no pattern flag, warn the user if so
|
||||
if (this.args.identifier.includes("*") && !this.flags.pattern) {
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.yellow(
|
||||
"⚠",
|
||||
)} Your identifier has asterisks but the --pattern flag is not set. This will match a literal string. If you want to use wildcards, set the --pattern flag.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const operator = this.flags.pattern ? like : eq;
|
||||
// Replace wildcards with an SQL LIKE pattern
|
||||
const identifier = this.flags.pattern
|
||||
? this.args.identifier.replace(/\*/g, "%")
|
||||
: this.args.identifier;
|
||||
|
||||
return await User.manyFromSql(
|
||||
and(
|
||||
this.flags.type === "id"
|
||||
? operator(Users.id, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "username"
|
||||
? operator(Users.username, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "note"
|
||||
? operator(Users.note, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "display-name"
|
||||
? operator(Users.displayName, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "email"
|
||||
? operator(Users.email, identifier)
|
||||
: undefined,
|
||||
),
|
||||
undefined,
|
||||
this.flags.limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class EmojiFinderCommand<
|
||||
T extends typeof BaseCommand,
|
||||
> extends BaseCommand<typeof EmojiFinderCommand> {
|
||||
static baseFlags = {
|
||||
pattern: Flags.boolean({
|
||||
char: "p",
|
||||
description:
|
||||
"Process as a wildcard pattern (don't forget to escape)",
|
||||
}),
|
||||
type: Flags.string({
|
||||
char: "t",
|
||||
description: "Type of identifier",
|
||||
options: ["shortcode", "instance"],
|
||||
default: "shortcode",
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of emojis",
|
||||
default: 100,
|
||||
}),
|
||||
print: Flags.boolean({
|
||||
allowNo: true,
|
||||
default: true,
|
||||
char: "P",
|
||||
description: "Print emoji(s) found before processing",
|
||||
}),
|
||||
};
|
||||
|
||||
static baseArgs = {
|
||||
identifier: Args.string({
|
||||
description: "Identifier of the emoji (defaults to shortcode)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
protected flags!: FlagsType<T>;
|
||||
protected args!: ArgsType<T>;
|
||||
|
||||
public async init(): Promise<void> {
|
||||
await super.init();
|
||||
const { args, flags } = await this.parse({
|
||||
flags: this.ctor.flags,
|
||||
baseFlags: (super.ctor as typeof BaseCommand).baseFlags,
|
||||
args: this.ctor.args,
|
||||
strict: this.ctor.strict,
|
||||
});
|
||||
this.flags = flags as FlagsType<T>;
|
||||
this.args = args as ArgsType<T>;
|
||||
}
|
||||
|
||||
public async findEmojis() {
|
||||
// Check if there are asterisks in the identifier but no pattern flag, warn the user if so
|
||||
if (this.args.identifier.includes("*") && !this.flags.pattern) {
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.yellow(
|
||||
"⚠",
|
||||
)} Your identifier has asterisks but the --pattern flag is not set. This will match a literal string. If you want to use wildcards, set the --pattern flag.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const operator = this.flags.pattern ? like : eq;
|
||||
// Replace wildcards with an SQL LIKE pattern
|
||||
const identifier = this.flags.pattern
|
||||
? this.args.identifier.replace(/\*/g, "%")
|
||||
: this.args.identifier;
|
||||
|
||||
return await db
|
||||
.select({
|
||||
...getTableColumns(Emojis),
|
||||
instanceUrl: Instances.baseUrl,
|
||||
})
|
||||
.from(Emojis)
|
||||
.leftJoin(Instances, eq(Emojis.instanceId, Instances.id))
|
||||
.where(
|
||||
and(
|
||||
this.flags.type === "shortcode"
|
||||
? operator(Emojis.shortcode, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "instance"
|
||||
? operator(Instances.baseUrl, identifier)
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
cli/commands/emoji/add.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { Args } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { getUrl } from "~database/entities/Attachment";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis } from "~drizzle/schema";
|
||||
import { config } from "~packages/config-manager";
|
||||
import { MediaBackend } from "~packages/media-manager";
|
||||
|
||||
export default class EmojiAdd extends BaseCommand<typeof EmojiAdd> {
|
||||
static override args = {
|
||||
shortcode: Args.string({
|
||||
description: "Shortcode of the emoji",
|
||||
required: true,
|
||||
}),
|
||||
file: Args.string({
|
||||
description: "Path to the image file (can be an URL)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override description = "Adds a new emoji";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> baba_yassie ./emojis/baba_yassie.png",
|
||||
"<%= config.bin %> <%= command.id %> baba_yassie https://example.com/emojis/baba_yassie.png",
|
||||
];
|
||||
|
||||
static override flags = {};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags, args } = await this.parse(EmojiAdd);
|
||||
|
||||
// Check if emoji already exists
|
||||
const existingEmoji = await db.query.Emojis.findFirst({
|
||||
where: (Emojis, { eq, and, isNull }) =>
|
||||
and(
|
||||
eq(Emojis.shortcode, args.shortcode),
|
||||
isNull(Emojis.instanceId),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingEmoji) {
|
||||
this.log(
|
||||
`${chalk.red("✗")} Emoji with shortcode ${chalk.red(
|
||||
args.shortcode,
|
||||
)} already exists`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
let file: File | null = null;
|
||||
|
||||
if (URL.canParse(args.file)) {
|
||||
const spinner = ora(
|
||||
`Downloading emoji from ${chalk.blue(
|
||||
chalk.underline(args.file),
|
||||
)}`,
|
||||
).start();
|
||||
|
||||
const response = await fetch(args.file, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
spinner.fail();
|
||||
this.log(
|
||||
`${chalk.red("✗")} Request returned status code ${chalk.red(
|
||||
response.status,
|
||||
)}`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
const filename =
|
||||
new URL(args.file).pathname.split("/").pop() ?? "emoji";
|
||||
|
||||
file = new File([await response.blob()], filename, {
|
||||
type:
|
||||
response.headers.get("Content-Type") ??
|
||||
"application/octet-stream",
|
||||
});
|
||||
|
||||
spinner.succeed();
|
||||
} else {
|
||||
const bunFile = Bun.file(args.file);
|
||||
file = new File(
|
||||
[await bunFile.arrayBuffer()],
|
||||
args.file.split("/").pop() ?? "emoji",
|
||||
{
|
||||
type: bunFile.type,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const media = await MediaBackend.fromBackendType(
|
||||
config.media.backend,
|
||||
config,
|
||||
);
|
||||
|
||||
const spinner = ora("Uploading emoji").start();
|
||||
|
||||
const uploaded = await media.addFile(file).catch((e: Error) => {
|
||||
spinner.fail();
|
||||
this.log(`${chalk.red("✗")} Error: ${chalk.red(e.message)}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!uploaded) {
|
||||
return this.exit(1);
|
||||
}
|
||||
|
||||
spinner.succeed();
|
||||
|
||||
const emoji = await db
|
||||
.insert(Emojis)
|
||||
.values({
|
||||
shortcode: args.shortcode,
|
||||
url: getUrl(uploaded.path, config),
|
||||
visibleInPicker: true,
|
||||
contentType: uploaded.uploadedFile.type,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!emoji || emoji.length === 0) {
|
||||
this.log(
|
||||
`${chalk.red("✗")} Failed to create emoji ${chalk.red(
|
||||
args.shortcode,
|
||||
)}`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
this.log(
|
||||
`${chalk.green("✓")} Created emoji ${chalk.green(
|
||||
args.shortcode,
|
||||
)} with url ${chalk.blue(
|
||||
chalk.underline(getUrl(uploaded.path, config)),
|
||||
)}`,
|
||||
);
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
101
cli/commands/emoji/delete.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import { Args, Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import ora from "ora";
|
||||
import { EmojiFinderCommand } from "~cli/classes";
|
||||
import { formatArray } from "~cli/utils/format";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis } from "~drizzle/schema";
|
||||
import { config } from "~packages/config-manager";
|
||||
import { MediaBackend } from "~packages/media-manager";
|
||||
|
||||
export default class EmojiDelete extends EmojiFinderCommand<
|
||||
typeof EmojiDelete
|
||||
> {
|
||||
static override args = {
|
||||
identifier: EmojiFinderCommand.baseArgs.identifier,
|
||||
};
|
||||
|
||||
static override description = "Deletes an emoji";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> baba_yassie",
|
||||
'<%= config.bin %> <%= command.id %> "baba\\*" --pattern',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before deleting the emoji (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags, args } = await this.parse(EmojiDelete);
|
||||
|
||||
const emojis = await this.findEmojis();
|
||||
|
||||
if (!emojis || emojis.length === 0) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} No emojis found`));
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Display user
|
||||
flags.print &&
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
emojis.length,
|
||||
)} emoji(s)`,
|
||||
),
|
||||
);
|
||||
|
||||
flags.print &&
|
||||
this.log(
|
||||
formatArray(emojis, [
|
||||
"id",
|
||||
"shortcode",
|
||||
"alt",
|
||||
"contentType",
|
||||
"instanceUrl",
|
||||
]),
|
||||
);
|
||||
|
||||
if (flags.confirm) {
|
||||
const choice = await confirm({
|
||||
message: `Are you sure you want to delete these emojis? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
|
||||
return this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora("Deleting emoji(s)").start();
|
||||
|
||||
for (const emoji of emojis) {
|
||||
spinner.text = `Deleting emoji ${chalk.gray(emoji.shortcode)} (${
|
||||
emojis.findIndex((e) => e.id === emoji.id) + 1
|
||||
}/${emojis.length})`;
|
||||
|
||||
const mediaBackend = await MediaBackend.fromBackendType(
|
||||
config.media.backend,
|
||||
config,
|
||||
);
|
||||
|
||||
await mediaBackend.deleteFileByUrl(emoji.url);
|
||||
|
||||
await db.delete(Emojis).where(eq(Emojis.id, emoji.id));
|
||||
}
|
||||
|
||||
spinner.succeed("Emoji(s) deleted");
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
255
cli/commands/emoji/import.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { Args, Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import { and, inArray, isNull } from "drizzle-orm";
|
||||
import { lookup } from "mime-types";
|
||||
import ora from "ora";
|
||||
import { unzip } from "unzipit";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { getUrl } from "~database/entities/Attachment";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis } from "~drizzle/schema";
|
||||
import { config } from "~packages/config-manager";
|
||||
import { MediaBackend } from "~packages/media-manager";
|
||||
|
||||
type MetaType = {
|
||||
emojis: {
|
||||
fileName: string;
|
||||
emoji: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export default class EmojiImport extends BaseCommand<typeof EmojiImport> {
|
||||
static override args = {
|
||||
path: Args.string({
|
||||
description: "Path to the emoji archive (can be an URL)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override description =
|
||||
"Imports emojis from a zip file (which can be fetched from a zip URL, e.g. for Pleroma emoji packs)";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> https://volpeon.ink/emojis/neocat/neocat.zip",
|
||||
"<%= config.bin %> <%= command.id %> export.zip",
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before deleting the emoji (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags, args } = await this.parse(EmojiImport);
|
||||
|
||||
// Check if path ends in .zip, warn the user if it doesn't
|
||||
if (!args.path.endsWith(".zip")) {
|
||||
this.log(
|
||||
`${chalk.yellow(
|
||||
"⚠",
|
||||
)} The path you provided does not end in .zip, this may not be a zip file. Proceeding anyway.`,
|
||||
);
|
||||
}
|
||||
|
||||
let file: File | null = null;
|
||||
|
||||
if (URL.canParse(args.path)) {
|
||||
const spinner = ora(
|
||||
`Downloading pack from ${chalk.blue(
|
||||
chalk.underline(args.path),
|
||||
)}`,
|
||||
).start();
|
||||
|
||||
const response = await fetch(args.path, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
spinner.fail();
|
||||
this.log(
|
||||
`${chalk.red("✗")} Request returned status code ${chalk.red(
|
||||
response.status,
|
||||
)}`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
const filename =
|
||||
new URL(args.path).pathname.split("/").pop() ?? "archive";
|
||||
|
||||
file = new File([await response.blob()], filename, {
|
||||
type:
|
||||
response.headers.get("Content-Type") ??
|
||||
"application/octet-stream",
|
||||
});
|
||||
|
||||
spinner.succeed();
|
||||
} else {
|
||||
const bunFile = Bun.file(args.path);
|
||||
file = new File(
|
||||
[await bunFile.arrayBuffer()],
|
||||
args.path.split("/").pop() ?? "archive",
|
||||
{
|
||||
type: bunFile.type,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const unzipSpinner = ora("Unzipping pack").start();
|
||||
|
||||
const { entries: unzipped } = await unzip(file);
|
||||
|
||||
unzipSpinner.succeed();
|
||||
|
||||
const entries = Object.entries(unzipped);
|
||||
|
||||
// Check if a meta.json file exists
|
||||
const metaExists = entries.find(([name]) => name === "meta.json");
|
||||
|
||||
if (metaExists) {
|
||||
this.log(`${chalk.green("✓")} Detected Pleroma meta.json, parsing`);
|
||||
}
|
||||
|
||||
const meta = metaExists
|
||||
? ((await metaExists[1].json()) as MetaType)
|
||||
: ({
|
||||
emojis: entries.map(([name]) => ({
|
||||
fileName: name,
|
||||
emoji: {
|
||||
name: name.split(".")[0],
|
||||
},
|
||||
})),
|
||||
} as MetaType);
|
||||
|
||||
// Get all emojis that already exist
|
||||
const existingEmojis = await db
|
||||
.select()
|
||||
.from(Emojis)
|
||||
.where(
|
||||
and(
|
||||
isNull(Emojis.instanceId),
|
||||
inArray(
|
||||
Emojis.shortcode,
|
||||
meta.emojis.map((e) => e.emoji.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Filter out existing emojis
|
||||
const newEmojis = meta.emojis.filter(
|
||||
(e) => !existingEmojis.find((ee) => ee.shortcode === e.emoji.name),
|
||||
);
|
||||
|
||||
existingEmojis.length > 0 &&
|
||||
this.log(
|
||||
`${chalk.yellow("⚠")} Emojis with shortcode ${chalk.yellow(
|
||||
existingEmojis.map((e) => e.shortcode).join(", "),
|
||||
)} already exist in the database and will not be imported`,
|
||||
);
|
||||
|
||||
if (newEmojis.length === 0) {
|
||||
this.log(`${chalk.red("✗")} No new emojis to import`);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
this.log(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
newEmojis.length,
|
||||
)} new emoji(s)`,
|
||||
);
|
||||
|
||||
const importSpinner = ora("Importing emojis").start();
|
||||
|
||||
const media = await MediaBackend.fromBackendType(
|
||||
config.media.backend,
|
||||
config,
|
||||
);
|
||||
|
||||
const successfullyImported: MetaType["emojis"] = [];
|
||||
|
||||
for (const emoji of newEmojis) {
|
||||
importSpinner.text = `Uploading ${chalk.gray(emoji.emoji.name)} (${
|
||||
newEmojis.indexOf(emoji) + 1
|
||||
}/${newEmojis.length})`;
|
||||
const zipEntry = unzipped[emoji.fileName];
|
||||
|
||||
if (!zipEntry) {
|
||||
this.log(
|
||||
`${chalk.red(
|
||||
"✗",
|
||||
)} Could not find file for emoji ${chalk.red(
|
||||
emoji.emoji.name,
|
||||
)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = emoji.fileName.split("/").pop() ?? "emoji";
|
||||
const contentType = lookup(fileName) || "application/octet-stream";
|
||||
|
||||
const newFile = new File([await zipEntry.arrayBuffer()], fileName, {
|
||||
type: contentType,
|
||||
});
|
||||
|
||||
const uploaded = await media.addFile(newFile).catch((e: Error) => {
|
||||
this.log(
|
||||
`${chalk.red("✗")} Error uploading ${chalk.red(
|
||||
emoji.emoji.name,
|
||||
)}: ${chalk.red(e.message)}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!uploaded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(Emojis)
|
||||
.values({
|
||||
shortcode: emoji.emoji.name,
|
||||
url: getUrl(uploaded.path, config),
|
||||
visibleInPicker: true,
|
||||
contentType: uploaded.uploadedFile.type,
|
||||
})
|
||||
.execute();
|
||||
|
||||
successfullyImported.push(emoji);
|
||||
}
|
||||
|
||||
importSpinner.succeed("Imported emojis");
|
||||
|
||||
successfullyImported.length > 0 &&
|
||||
this.log(
|
||||
`${chalk.green("✓")} Successfully imported ${chalk.green(
|
||||
successfullyImported.length,
|
||||
)} emoji(s)`,
|
||||
);
|
||||
|
||||
newEmojis.length - successfullyImported.length > 0 &&
|
||||
this.log(
|
||||
`${chalk.yellow("⚠")} Failed to import ${chalk.yellow(
|
||||
newEmojis.length - successfullyImported.length,
|
||||
)} emoji(s): ${chalk.yellow(
|
||||
newEmojis
|
||||
.filter((e) => !successfullyImported.includes(e))
|
||||
.map((e) => e.emoji.name)
|
||||
.join(", "),
|
||||
)}`,
|
||||
);
|
||||
|
||||
if (successfullyImported.length === 0) {
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
86
cli/commands/emoji/list.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { Flags } from "@oclif/core";
|
||||
import { and, eq, getTableColumns, isNotNull, isNull } from "drizzle-orm";
|
||||
import { BaseCommand } from "~cli/base";
|
||||
import { formatArray } from "~cli/utils/format";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis, Instances, Users } from "~drizzle/schema";
|
||||
|
||||
export default class EmojiList extends BaseCommand<typeof EmojiList> {
|
||||
static override args = {};
|
||||
|
||||
static override description = "List all emojis";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> --format json --local",
|
||||
"<%= config.bin %> <%= command.id %>",
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
format: Flags.string({
|
||||
char: "f",
|
||||
description: "Output format",
|
||||
options: ["json", "csv"],
|
||||
}),
|
||||
local: Flags.boolean({
|
||||
char: "l",
|
||||
description: "Local emojis only",
|
||||
exclusive: ["remote"],
|
||||
}),
|
||||
remote: Flags.boolean({
|
||||
char: "r",
|
||||
description: "Remote emojis only",
|
||||
exclusive: ["local"],
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of emojis",
|
||||
default: 200,
|
||||
}),
|
||||
username: Flags.string({
|
||||
char: "u",
|
||||
description: "Filter by username",
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(EmojiList);
|
||||
|
||||
const emojis = await db
|
||||
.select({
|
||||
...getTableColumns(Emojis),
|
||||
instanceUrl: Instances.baseUrl,
|
||||
owner: Users.username,
|
||||
})
|
||||
.from(Emojis)
|
||||
.leftJoin(Instances, eq(Emojis.instanceId, Instances.id))
|
||||
.leftJoin(Users, eq(Emojis.ownerId, Users.id))
|
||||
.where(
|
||||
and(
|
||||
flags.local ? isNull(Emojis.instanceId) : undefined,
|
||||
flags.remote ? isNotNull(Emojis.instanceId) : undefined,
|
||||
flags.username
|
||||
? eq(Users.username, flags.username)
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const keys = [
|
||||
"id",
|
||||
"shortcode",
|
||||
"alt",
|
||||
"contentType",
|
||||
"instanceUrl",
|
||||
"owner",
|
||||
];
|
||||
|
||||
this.log(
|
||||
formatArray(
|
||||
emojis,
|
||||
keys,
|
||||
flags.format as "json" | "csv" | undefined,
|
||||
),
|
||||
);
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
54
cli/commands/start.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import os from "node:os";
|
||||
import { Flags } from "@oclif/core";
|
||||
import { BaseCommand } from "~cli/base";
|
||||
|
||||
export default class Start extends BaseCommand<typeof Start> {
|
||||
static override args = {};
|
||||
|
||||
static override description = "Starts Lysand";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> --threads 4",
|
||||
"<%= config.bin %> <%= command.id %> --all-threads",
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
threads: Flags.integer({
|
||||
char: "t",
|
||||
description: "Number of threads to use",
|
||||
default: 1,
|
||||
exclusive: ["all-threads"],
|
||||
}),
|
||||
"all-threads": Flags.boolean({
|
||||
description: "Use all available threads",
|
||||
default: false,
|
||||
exclusive: ["threads"],
|
||||
}),
|
||||
silent: Flags.boolean({
|
||||
description: "Don't show logs in console",
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Start);
|
||||
|
||||
const numCPUs = flags["all-threads"] ? os.cpus().length : flags.threads;
|
||||
|
||||
// Check if index is a JS or TS file (depending on the environment)
|
||||
const index = (await Bun.file("index.ts").exists())
|
||||
? "index.ts"
|
||||
: "index.js";
|
||||
|
||||
for (let i = 0; i < numCPUs; i++) {
|
||||
const args = ["bun", index];
|
||||
if (i !== 0 || flags.silent) {
|
||||
args.push("--silent");
|
||||
}
|
||||
Bun.spawn(args, {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
162
cli/commands/user/create.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import input from "@inquirer/input";
|
||||
import { Args, Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
import { BaseCommand } from "~cli/base";
|
||||
import { formatArray } from "~cli/utils/format";
|
||||
import { Users } from "~drizzle/schema";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
|
||||
export default class UserCreate extends BaseCommand<typeof UserCreate> {
|
||||
static override args = {
|
||||
username: Args.string({
|
||||
description: "Username",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override description = "Creates a new user";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> johngastron --email joe@gamer.com",
|
||||
"<%= config.bin %> <%= command.id %> bimbobaggins",
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
format: Flags.string({
|
||||
char: "f",
|
||||
description:
|
||||
"Output format (when set, no password reset link is generated)",
|
||||
options: ["json", "csv"],
|
||||
}),
|
||||
admin: Flags.boolean({
|
||||
char: "a",
|
||||
description: "Admin user",
|
||||
allowNo: true,
|
||||
default: false,
|
||||
}),
|
||||
email: Flags.string({
|
||||
char: "e",
|
||||
description: "Email",
|
||||
}),
|
||||
"verify-email": Flags.boolean({
|
||||
description: "Send email verification",
|
||||
default: true,
|
||||
allowNo: true,
|
||||
}),
|
||||
"set-password": Flags.boolean({
|
||||
description: "Type password instead of getting a reset link",
|
||||
default: false,
|
||||
exclusive: ["format"],
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags, args } = await this.parse(UserCreate);
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.fromSql(
|
||||
eq(Users.username, args.username),
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
this.log(
|
||||
`${chalk.red("✗")} User ${chalk.red(
|
||||
args.username,
|
||||
)} already exists`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
let password = null;
|
||||
|
||||
if (flags["set-password"]) {
|
||||
const password1 = await input({
|
||||
message: "Please enter the user's password:",
|
||||
// Set whatever the user types to stars
|
||||
transformer: (value) => "*".repeat(value.length),
|
||||
});
|
||||
|
||||
const password2 = await input({
|
||||
message: "Please confirm the user's password:",
|
||||
// Set whatever the user types to stars
|
||||
transformer: (value) => "*".repeat(value.length),
|
||||
});
|
||||
|
||||
if (password1 !== password2) {
|
||||
this.log(
|
||||
`${chalk.red(
|
||||
"✗",
|
||||
)} Passwords do not match. Please try again.`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
password = password1;
|
||||
}
|
||||
|
||||
// TODO: Add password resets
|
||||
|
||||
const user = await User.fromDataLocal({
|
||||
email: flags.email ?? undefined,
|
||||
password: password ?? undefined,
|
||||
username: args.username,
|
||||
admin: flags.admin,
|
||||
skipPasswordHash: !password,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
this.log(
|
||||
`${chalk.red("✗")} Failed to create user ${chalk.red(
|
||||
args.username,
|
||||
)}`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
!flags.format &&
|
||||
this.log(
|
||||
`${chalk.green("✓")} Created user ${chalk.green(
|
||||
user.getUser().username,
|
||||
)} with id ${chalk.green(user.id)}`,
|
||||
);
|
||||
|
||||
this.log(
|
||||
formatArray(
|
||||
[user.getUser()],
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
],
|
||||
flags.format as "json" | "csv" | undefined,
|
||||
),
|
||||
);
|
||||
|
||||
if (!flags.format && !flags["set-password"]) {
|
||||
const link = "";
|
||||
|
||||
this.log(
|
||||
flags.format
|
||||
? link
|
||||
: `\nPassword reset link for ${chalk.bold(
|
||||
`@${user.getUser().username}`,
|
||||
)}: ${chalk.underline(chalk.blue(link))}\n`,
|
||||
);
|
||||
|
||||
const qrcode = renderUnicodeCompact(link, {
|
||||
border: 2,
|
||||
});
|
||||
|
||||
// Pad all lines of QR code with spaces
|
||||
|
||||
this.log(` ${qrcode.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
90
cli/commands/user/delete.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import { Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { UserFinderCommand } from "~cli/classes";
|
||||
import { formatArray } from "~cli/utils/format";
|
||||
|
||||
export default class UserDelete extends UserFinderCommand<typeof UserDelete> {
|
||||
static override description = "Deletes users";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> johngastron --type username",
|
||||
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
|
||||
'<%= config.bin %> <%= command.id %> "*badword*" --pattern --type username',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before deleting the user (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override args = {
|
||||
identifier: UserFinderCommand.baseArgs.identifier,
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags, args } = await this.parse(UserDelete);
|
||||
|
||||
const users = await this.findUsers();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Display user
|
||||
flags.print &&
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
users.length,
|
||||
)} user(s)`,
|
||||
),
|
||||
);
|
||||
|
||||
flags.print &&
|
||||
this.log(
|
||||
formatArray(
|
||||
users.map((u) => u.getUser()),
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (flags.confirm) {
|
||||
const choice = await confirm({
|
||||
message: `Are you sure you want to delete these users? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
|
||||
return this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora("Deleting user(s)").start();
|
||||
|
||||
for (const user of users) {
|
||||
await user.delete();
|
||||
}
|
||||
|
||||
spinner.succeed();
|
||||
|
||||
this.log(chalk.bold(`${chalk.green("✓")} User(s) deleted`));
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
84
cli/commands/user/list.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Flags } from "@oclif/core";
|
||||
import { and, eq, isNotNull, isNull } from "drizzle-orm";
|
||||
import { BaseCommand } from "~cli/base";
|
||||
import { formatArray } from "~cli/utils/format";
|
||||
import { Users } from "~drizzle/schema";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
|
||||
export default class UserList extends BaseCommand<typeof UserList> {
|
||||
static override args = {};
|
||||
|
||||
static override description = "List all users";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> --format json --local",
|
||||
"<%= config.bin %> <%= command.id %>",
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
format: Flags.string({
|
||||
char: "f",
|
||||
description: "Output format",
|
||||
options: ["json", "csv"],
|
||||
exclusive: ["pretty-dates"],
|
||||
}),
|
||||
local: Flags.boolean({
|
||||
char: "l",
|
||||
description: "Local users only",
|
||||
exclusive: ["remote"],
|
||||
}),
|
||||
remote: Flags.boolean({
|
||||
char: "r",
|
||||
description: "Remote users only",
|
||||
exclusive: ["local"],
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of users",
|
||||
default: 200,
|
||||
}),
|
||||
admin: Flags.boolean({
|
||||
char: "a",
|
||||
description: "Admin users only",
|
||||
allowNo: true,
|
||||
}),
|
||||
"pretty-dates": Flags.boolean({
|
||||
char: "p",
|
||||
description: "Pretty print dates",
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(UserList);
|
||||
|
||||
const users = await User.manyFromSql(
|
||||
and(
|
||||
flags.local ? isNull(Users.instanceId) : undefined,
|
||||
flags.remote ? isNotNull(Users.instanceId) : undefined,
|
||||
flags.admin ? eq(Users.isAdmin, flags.admin) : undefined,
|
||||
),
|
||||
undefined,
|
||||
flags.limit,
|
||||
);
|
||||
|
||||
const keys = [
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
];
|
||||
|
||||
this.log(
|
||||
formatArray(
|
||||
users.map((u) => u.getUser()),
|
||||
keys,
|
||||
flags.format as "json" | "csv" | undefined,
|
||||
flags["pretty-dates"],
|
||||
),
|
||||
);
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
126
cli/commands/user/reset.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import { Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
import { UserFinderCommand } from "~cli/classes";
|
||||
import { formatArray } from "~cli/utils/format";
|
||||
import { config } from "~packages/config-manager";
|
||||
|
||||
export default class UserReset extends UserFinderCommand<typeof UserReset> {
|
||||
static override description = "Resets users' passwords";
|
||||
|
||||
static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> johngastron --type username",
|
||||
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before deleting the user (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of users",
|
||||
default: 1,
|
||||
}),
|
||||
raw: Flags.boolean({
|
||||
description:
|
||||
"Only output the password reset link (implies --no-print and --no-confirm)",
|
||||
}),
|
||||
};
|
||||
|
||||
static override args = {
|
||||
identifier: UserFinderCommand.baseArgs.identifier,
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags, args } = await this.parse(UserReset);
|
||||
|
||||
const users = await this.findUsers();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Display user
|
||||
!flags.raw &&
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
users.length,
|
||||
)} user(s)`,
|
||||
),
|
||||
);
|
||||
|
||||
!flags.raw &&
|
||||
flags.print &&
|
||||
this.log(
|
||||
formatArray(
|
||||
users.map((u) => u.getUser()),
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (flags.confirm && !flags.raw) {
|
||||
const choice = await confirm({
|
||||
message: `Reset these users's passwords? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
|
||||
return this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
const token = await user.resetPassword();
|
||||
|
||||
const link = new URL(
|
||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
||||
{
|
||||
token,
|
||||
},
|
||||
).toString()}`,
|
||||
config.http.base_url,
|
||||
).toString();
|
||||
|
||||
!flags.raw &&
|
||||
this.log(
|
||||
`${chalk.green("✓")} Password reset for ${
|
||||
users.length
|
||||
} user(s)`,
|
||||
);
|
||||
|
||||
this.log(
|
||||
flags.raw
|
||||
? link
|
||||
: `\nPassword reset link for ${chalk.bold(
|
||||
`@${user.getUser().username}`,
|
||||
)}: ${chalk.underline(chalk.blue(link))}\n`,
|
||||
);
|
||||
|
||||
const qrcode = renderUnicodeCompact(link, {
|
||||
border: 2,
|
||||
});
|
||||
|
||||
// Pad all lines of QR code with spaces
|
||||
|
||||
!flags.raw && this.log(` ${qrcode.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
59
cli/index.ts
|
|
@ -1,36 +1,27 @@
|
|||
import { completionsPlugin } from "@clerc/plugin-completions";
|
||||
import { friendlyErrorPlugin } from "@clerc/plugin-friendly-error";
|
||||
import { helpPlugin } from "@clerc/plugin-help";
|
||||
import { notFoundPlugin } from "@clerc/plugin-not-found";
|
||||
import { versionPlugin } from "@clerc/plugin-version";
|
||||
import { setupDatabase } from "@versia-server/kit/db";
|
||||
import { searchManager } from "@versia-server/kit/search";
|
||||
import { Clerc } from "clerc";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
||||
import { refetchInstanceCommand } from "./instance/refetch.ts";
|
||||
import { createUserCommand } from "./user/create.ts";
|
||||
import { deleteUserCommand } from "./user/delete.ts";
|
||||
import { refetchUserCommand } from "./user/refetch.ts";
|
||||
import { generateTokenCommand } from "./user/token.ts";
|
||||
import { execute } from "@oclif/core";
|
||||
import EmojiAdd from "./commands/emoji/add";
|
||||
import EmojiDelete from "./commands/emoji/delete";
|
||||
import EmojiImport from "./commands/emoji/import";
|
||||
import EmojiList from "./commands/emoji/list";
|
||||
import Start from "./commands/start";
|
||||
import UserCreate from "./commands/user/create";
|
||||
import UserDelete from "./commands/user/delete";
|
||||
import UserList from "./commands/user/list";
|
||||
import UserReset from "./commands/user/reset";
|
||||
|
||||
await setupDatabase(false);
|
||||
await searchManager.connect(true);
|
||||
// Use "explicit" oclif strategy to avoid issues with oclif's module resolver and bundling
|
||||
export const commands = {
|
||||
"user:list": UserList,
|
||||
"user:delete": UserDelete,
|
||||
"user:create": UserCreate,
|
||||
"user:reset": UserReset,
|
||||
"emoji:add": EmojiAdd,
|
||||
"emoji:delete": EmojiDelete,
|
||||
"emoji:list": EmojiList,
|
||||
"emoji:import": EmojiImport,
|
||||
start: Start,
|
||||
};
|
||||
|
||||
Clerc.create()
|
||||
.scriptName("cli")
|
||||
.name("Versia Server CLI")
|
||||
.description("CLI interface for Versia Server")
|
||||
.version(pkg.version)
|
||||
.use(helpPlugin())
|
||||
.use(versionPlugin())
|
||||
.use(completionsPlugin())
|
||||
.use(notFoundPlugin())
|
||||
.use(friendlyErrorPlugin())
|
||||
.command(createUserCommand)
|
||||
.command(deleteUserCommand)
|
||||
.command(generateTokenCommand)
|
||||
.command(refetchUserCommand)
|
||||
.command(rebuildIndexCommand)
|
||||
.command(refetchInstanceCommand)
|
||||
.parse();
|
||||
if (import.meta.path === Bun.main) {
|
||||
await execute({ dir: import.meta.url });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { SonicIndexType, searchManager } from "@versia-server/kit/search";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import ora from "ora";
|
||||
|
||||
export const rebuildIndexCommand = defineCommand(
|
||||
{
|
||||
name: "index rebuild",
|
||||
description: "Rebuild the search index.",
|
||||
parameters: ["<type>"],
|
||||
flags: {
|
||||
"batch-size": {
|
||||
description: "Number of records to process at once",
|
||||
type: Number,
|
||||
alias: "b",
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { "batch-size": batchSize } = context.flags;
|
||||
const { type } = context.parameters;
|
||||
|
||||
if (!config.search.enabled) {
|
||||
throw new Error(
|
||||
"Search is not enabled in the instance configuration.",
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora("Rebuilding search indexes").start();
|
||||
|
||||
switch (type) {
|
||||
case "accounts":
|
||||
await searchManager.rebuildSearchIndexes(
|
||||
[SonicIndexType.Accounts],
|
||||
batchSize,
|
||||
(progress) => {
|
||||
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "statuses":
|
||||
await searchManager.rebuildSearchIndexes(
|
||||
[SonicIndexType.Statuses],
|
||||
batchSize,
|
||||
(progress) => {
|
||||
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
|
||||
},
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
throw new Error(
|
||||
"Invalid index type. Can be 'accounts' or 'statuses'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed("Search indexes rebuilt");
|
||||
},
|
||||
);
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { Instance } from "@versia-server/kit/db";
|
||||
import { FetchJobType, fetchQueue } from "@versia-server/kit/queues/fetch";
|
||||
import { Instances } from "@versia-server/kit/tables";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const refetchInstanceCommand = defineCommand(
|
||||
{
|
||||
name: "instance refetch",
|
||||
description: "Refetches metadata from remote instances.",
|
||||
parameters: ["<url_or_host>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { urlOrHost } = context.parameters;
|
||||
|
||||
const host = URL.canParse(urlOrHost)
|
||||
? new URL(urlOrHost).host
|
||||
: urlOrHost;
|
||||
|
||||
const instance = await Instance.fromSql(eq(Instances.baseUrl, host));
|
||||
|
||||
if (!instance) {
|
||||
throw new Error(`Instance ${chalk.gray(host)} not found.`);
|
||||
}
|
||||
|
||||
await fetchQueue.add(FetchJobType.Instance, {
|
||||
uri: new URL(`https://${instance.data.baseUrl}`).origin,
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Refresh job enqueued for ${chalk.gray(instance.data.baseUrl)}.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
15
cli/theme.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"bin": "white",
|
||||
"command": "cyan",
|
||||
"commandSummary": "white",
|
||||
"dollarSign": "white",
|
||||
"flag": "white",
|
||||
"flagDefaultValue": "blue",
|
||||
"flagOptions": "green",
|
||||
"flagRequired": "red",
|
||||
"flagSeparator": "white",
|
||||
"sectionDescription": "white",
|
||||
"sectionHeader": "underline",
|
||||
"topic": "white",
|
||||
"version": "green"
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { searchManager } from "@versia-server/kit/search";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
|
||||
export const createUserCommand = defineCommand(
|
||||
{
|
||||
name: "user create",
|
||||
description: "Create a new user.",
|
||||
parameters: ["<username>"],
|
||||
flags: {
|
||||
password: {
|
||||
description: "Password for the new user",
|
||||
type: String,
|
||||
alias: "p",
|
||||
},
|
||||
email: {
|
||||
description: "Email for the new user",
|
||||
type: String,
|
||||
alias: "e",
|
||||
},
|
||||
admin: {
|
||||
description: "Make the new user an admin",
|
||||
type: Boolean,
|
||||
alias: "a",
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { admin, email, password } = context.flags;
|
||||
const { username } = context.parameters;
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(username)) {
|
||||
throw new Error("Username must be alphanumeric and lowercase.");
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.fromSql(
|
||||
and(eq(Users.username, username), isNull(Users.instanceId)),
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error(`User ${chalk.gray(username)} is taken.`);
|
||||
}
|
||||
|
||||
const user = await User.register(username, {
|
||||
email,
|
||||
password,
|
||||
isAdmin: admin,
|
||||
});
|
||||
|
||||
// Add to search index
|
||||
await searchManager.addUser(user);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user.");
|
||||
}
|
||||
|
||||
console.info(`User ${chalk.gray(username)} created.`);
|
||||
|
||||
if (!password) {
|
||||
const token = await user.resetPassword();
|
||||
|
||||
const link = new URL(
|
||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
||||
{
|
||||
token,
|
||||
},
|
||||
)}`,
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
console.info(`Password reset link for ${chalk.gray(username)}:`);
|
||||
console.info(chalk.blue(link.href));
|
||||
|
||||
const qrcode = renderUnicodeCompact(link.href, {
|
||||
border: 2,
|
||||
});
|
||||
|
||||
// Pad all lines of QR code with spaces
|
||||
console.info(`\n ${qrcode.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const deleteUserCommand = defineCommand(
|
||||
{
|
||||
name: "user delete",
|
||||
alias: "user rm",
|
||||
description:
|
||||
"Delete a user from the database. Can use username or handle.",
|
||||
parameters: ["<username_or_handle>"],
|
||||
flags: {
|
||||
confirm: {
|
||||
description: "Ask for confirmation before deleting the user",
|
||||
type: Boolean,
|
||||
alias: "c",
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { confirm: confirmFlag } = context.flags;
|
||||
const { usernameOrHandle } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(usernameOrHandle);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(usernameOrHandle)} not found.`);
|
||||
}
|
||||
|
||||
console.info(`About to delete user ${chalk.gray(user.data.username)}!`);
|
||||
console.info(`Username: ${chalk.blue(user.data.username)}`);
|
||||
console.info(`Display Name: ${chalk.blue(user.data.displayName)}`);
|
||||
console.info(`Created At: ${chalk.blue(user.data.createdAt)}`);
|
||||
console.info(
|
||||
`Instance: ${chalk.blue(user.data.instance?.baseUrl || "Local")}`,
|
||||
);
|
||||
|
||||
if (confirmFlag) {
|
||||
const choice = await confirm({
|
||||
message: `Are you sure you want to delete this user? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
throw new Error("Operation aborted.");
|
||||
}
|
||||
}
|
||||
|
||||
await user.delete();
|
||||
|
||||
console.info(
|
||||
`User ${chalk.gray(user.data.username)} has been deleted.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { User } from "@versia-server/kit/db";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import ora from "ora";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const refetchUserCommand = defineCommand(
|
||||
{
|
||||
name: "user refetch",
|
||||
description: "Refetches user data from their remote instance.",
|
||||
parameters: ["<handle>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { handle } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(handle);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(handle)} not found.`);
|
||||
}
|
||||
|
||||
if (user.local) {
|
||||
throw new Error(
|
||||
"This user is local and as such cannot be refetched.",
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora("Refetching user").start();
|
||||
|
||||
try {
|
||||
await User.fromVersia(user.uri);
|
||||
} catch (error) {
|
||||
spinner.fail(
|
||||
`Failed to refetch user ${chalk.gray(user.data.username)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
spinner.succeed(`User ${chalk.gray(user.data.username)} refetched.`);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { Client, Token } from "@versia-server/kit/db";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { randomString } from "@/math.ts";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const generateTokenCommand = defineCommand(
|
||||
{
|
||||
name: "user token",
|
||||
description: "Generates a new access token for a user.",
|
||||
parameters: ["<username>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { username } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(username);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(username)} not found.`);
|
||||
}
|
||||
|
||||
const application = await Client.insert({
|
||||
id:
|
||||
user.id +
|
||||
Buffer.from(
|
||||
crypto.getRandomValues(new Uint8Array(32)),
|
||||
).toString("base64"),
|
||||
name: "Versia",
|
||||
redirectUris: [],
|
||||
scopes: ["openid", "profile", "email"],
|
||||
secret: "",
|
||||
});
|
||||
|
||||
const token = await Token.insert({
|
||||
id: randomUUIDv7(),
|
||||
accessToken: randomString(64, "base64url"),
|
||||
scopes: ["read", "write", "follow"],
|
||||
userId: user.id,
|
||||
clientId: application.id,
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Token generated for user ${chalk.gray(user.data.username)}.`,
|
||||
);
|
||||
console.info(`Access Token: ${chalk.blue(token.data.accessToken)}`);
|
||||
},
|
||||
);
|
||||
23
cli/utils.ts
|
|
@ -1,23 +0,0 @@
|
|||
import { Instance, User } from "@versia-server/kit/db";
|
||||
import { parseUserAddress } from "@versia-server/kit/parsers";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
|
||||
export const retrieveUser = async (
|
||||
usernameOrHandle: string,
|
||||
): Promise<User | null> => {
|
||||
const { username, domain } = parseUserAddress(usernameOrHandle);
|
||||
|
||||
const instance = domain ? await Instance.resolveFromHost(domain) : null;
|
||||
|
||||
const user = await User.fromSql(
|
||||
and(
|
||||
eq(Users.username, username),
|
||||
instance
|
||||
? eq(Users.instanceId, instance.data.id)
|
||||
: isNull(Users.instanceId),
|
||||
),
|
||||
);
|
||||
|
||||
return user;
|
||||
};
|
||||
70
cli/utils/format.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import chalk from "chalk";
|
||||
import { getBorderCharacters, table } from "table";
|
||||
|
||||
/**
|
||||
* Given a JS array, return a string output to be passed to console.log
|
||||
* @param arr The array to be formatted
|
||||
* @param keys The keys to be displayed (removes all other keys from the output)
|
||||
* @param type Either "json", "csv" or nothing for a table
|
||||
* @returns The formatted string
|
||||
*/
|
||||
export const formatArray = (
|
||||
arr: Record<string, unknown>[],
|
||||
keys: string[],
|
||||
type?: "json" | "csv",
|
||||
prettyDates = false,
|
||||
): string => {
|
||||
const output = arr.map((item) => {
|
||||
const newItem = {} as Record<string, unknown>;
|
||||
|
||||
for (const key of keys) {
|
||||
newItem[key] = item[key];
|
||||
}
|
||||
|
||||
return newItem;
|
||||
});
|
||||
|
||||
if (prettyDates) {
|
||||
for (const item of output) {
|
||||
for (const key of keys) {
|
||||
const value = item[key];
|
||||
// If this is an ISO string, convert it to a nice date
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}$/)
|
||||
) {
|
||||
item[key] = Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(new Date(value));
|
||||
// Format using Chalk
|
||||
item[key] = chalk.underline(item[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "json":
|
||||
return JSON.stringify(output, null, 2);
|
||||
case "csv":
|
||||
return `${keys.join(",")}\n${output
|
||||
.map((item) => keys.map((key) => item[key]).join(","))
|
||||
.join("\n")}`;
|
||||
default:
|
||||
// Convert output to array of arrays for table
|
||||
return table(
|
||||
[
|
||||
keys.map((k) => chalk.bold(k)),
|
||||
...output.map((item) => keys.map((key) => item[key])),
|
||||
],
|
||||
{
|
||||
border: getBorderCharacters("norc"),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
../config
|
||||
|
|
@ -1,69 +1,81 @@
|
|||
# You can change the URL to the commit/tag you are using
|
||||
#:schema https://raw.githubusercontent.com/versia-pub/server/main/config/config.schema.json
|
||||
|
||||
# All values marked as "sensitive" can be set to "PATH:/path/to/file" to read the value from a file (e.g. a secret manager)
|
||||
|
||||
|
||||
[postgres]
|
||||
# PostgreSQL database configuration
|
||||
[database]
|
||||
# Main PostgreSQL database connection
|
||||
host = "localhost"
|
||||
port = 5432
|
||||
username = "versia"
|
||||
# Sensitive value
|
||||
username = "lysand"
|
||||
password = "mycoolpassword"
|
||||
database = "versia"
|
||||
|
||||
# Additional read-only replicas
|
||||
# [[postgres.replicas]]
|
||||
# host = "other-host"
|
||||
# port = 5432
|
||||
# username = "versia"
|
||||
# password = "mycoolpassword2"
|
||||
# database = "replica1"
|
||||
database = "lysand"
|
||||
|
||||
[redis.queue]
|
||||
# A Redis database used for managing queues.
|
||||
# Redis instance for storing the federation queue
|
||||
# Required for federation
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
# Sensitive value
|
||||
# password = "test"
|
||||
password = ""
|
||||
database = 0
|
||||
enabled = true
|
||||
|
||||
# A Redis database used for caching SQL queries.
|
||||
[redis.cache]
|
||||
# Redis instance to be used as a timeline cache
|
||||
# Optional, can be the same as the queue instance
|
||||
# [redis.cache]
|
||||
# host = "localhost"
|
||||
# port = 6380
|
||||
# database = 1
|
||||
# password = ""
|
||||
|
||||
# Search and indexing configuration
|
||||
[search]
|
||||
# Enable indexing and searching?
|
||||
host = "localhost"
|
||||
port = 40004
|
||||
password = ""
|
||||
database = 1
|
||||
enabled = false
|
||||
|
||||
# Optional if search is disabled
|
||||
# [search.sonic]
|
||||
# host = "localhost"
|
||||
# port = 7700
|
||||
# Sensitive value
|
||||
# password = "test"
|
||||
[meilisearch]
|
||||
# If Meilisearch is not configured, search will not be enabled
|
||||
host = "localhost"
|
||||
port = 40007
|
||||
api_key = ""
|
||||
enabled = true
|
||||
|
||||
[registration]
|
||||
# Can users sign up freely?
|
||||
allow = true
|
||||
# NOT IMPLEMENTED
|
||||
require_approval = false
|
||||
# Message to show to users when registration is disabled
|
||||
# message = "ran out of spoons to moderate registrations, sorry"
|
||||
[signups]
|
||||
# URL of your Terms of Service
|
||||
tos_url = "https://social.lysand.org/tos"
|
||||
# Whether to enable registrations or not
|
||||
registration = true
|
||||
rules = [
|
||||
"Do not harass others",
|
||||
"Be nice to people",
|
||||
"Don't spam",
|
||||
"Don't post illegal content",
|
||||
]
|
||||
|
||||
[oidc]
|
||||
# Run Lysand with this value missing to generate a new key
|
||||
jwt_key = ""
|
||||
|
||||
# If enabled, Lysand will require users to log in with an OAuth provider
|
||||
# Note that registering with an OAuth provider is not supported yet, so
|
||||
# this will lock out users who are not already registered or who do not have
|
||||
# an OAuth account linked
|
||||
forced = false
|
||||
|
||||
# Delete this section if you don't want to use custom OAuth providers
|
||||
# This is an example configuration
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
# Most notably, GitHub does not support this
|
||||
# Redirect URLs in your OAuth provider can be set to this:
|
||||
# <base_url>/oauth/sso/<provider_id>/callback*
|
||||
# The asterisk is important, as it allows for any query parameters to be passed
|
||||
# Authentik for example uses regex so it can be set to (regex):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
[[oidc.providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# url = "https://id.cpluspatch.com/application/o/lysand-testing/"
|
||||
# client_id = "XXXX"
|
||||
# client_secret = "XXXXX"
|
||||
# icon = "https://cpluspatch.com/images/icons/logo.svg"
|
||||
|
||||
[http]
|
||||
# URL that the instance will be accessible at
|
||||
base_url = "https://example.com"
|
||||
# The full URL Lysand will be reachable by (paths are not supported)
|
||||
base_url = "https://lysand.localhost:9900"
|
||||
# Address to bind to (0.0.0.0 is suggested for proxies)
|
||||
bind = "0.0.0.0"
|
||||
bind_port = 8080
|
||||
bind = "lysand.localhost"
|
||||
bind_port = 9900
|
||||
|
||||
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
|
||||
banned_ips = []
|
||||
|
|
@ -73,31 +85,34 @@ banned_user_agents = [
|
|||
# "wget\/1.20.3",
|
||||
]
|
||||
|
||||
# URL to an eventual HTTP proxy
|
||||
# Will be used for all outgoing requests
|
||||
# proxy_address = "http://localhost:8118"
|
||||
[http.tls]
|
||||
# If these values are set, Lysand will use these files for TLS
|
||||
enabled = false
|
||||
key = ""
|
||||
cert = ""
|
||||
passphrase = ""
|
||||
ca = ""
|
||||
|
||||
# TLS configuration. You should probably be using a reverse proxy instead of this
|
||||
# [http.tls]
|
||||
# key = "/path/to/key.pem"
|
||||
# cert = "/path/to/cert.pem"
|
||||
# Sensitive value
|
||||
# passphrase = "awawa"
|
||||
# ca = "/path/to/ca.pem"
|
||||
[http.bait]
|
||||
# Enable the bait feature (sends fake data to those who are flagged)
|
||||
enabled = false
|
||||
# Path to file of bait data (if not provided, Lysand will send the entire Bee Movie script)
|
||||
send_file = ""
|
||||
# IPs to send bait data to (wildcards, networks and ranges are supported)
|
||||
bait_ips = ["127.0.0.1", "::1"]
|
||||
# User agents to send bait data to (regex format)
|
||||
bait_user_agents = ["curl", "wget"]
|
||||
|
||||
[frontend]
|
||||
# Enable custom frontends (warning: not enabling this will make Versia Server only accessible via the Mastodon API)
|
||||
# Frontends also control the OpenID flow, so if you disable this, you will need to use the Mastodon frontend
|
||||
# Enable custom frontends (warning: not enabling this or Glitch will make Lysand only accessible via the Mastodon API)
|
||||
# Frontends also control the OAuth flow, so if you disable this, you will need to use the Mastodon frontend
|
||||
enabled = true
|
||||
# Path that frontend files are served from
|
||||
# Edit this property to serve custom frontends
|
||||
# If this is not set, Versia Server will also check
|
||||
# the VERSIA_FRONTEND_PATH environment variable
|
||||
# path = ""
|
||||
# The URL to reach the frontend at (should be on a local network)
|
||||
url = "http://localhost:3000"
|
||||
|
||||
[frontend.routes]
|
||||
# Special routes for your frontend, below are the defaults for Versia-FE
|
||||
# Can be set to a route already used by Versia Server, as long as it is on a different HTTP method
|
||||
# Special routes for your frontend, below are the defaults for Lysand-FE
|
||||
# Can be set to a route already used by Lysand, as long as it is on a different HTTP method
|
||||
# e.g. /oauth/authorize is a POST-only route, so you can serve a GET route at /oauth/authorize
|
||||
# home = "/"
|
||||
# login = "/oauth/authorize"
|
||||
|
|
@ -110,29 +125,36 @@ enabled = true
|
|||
# This can be used to set up custom themes, etc on supported frontends.
|
||||
# theme = "dark"
|
||||
|
||||
# NOT IMPLEMENTED
|
||||
[email]
|
||||
# Enable email sending
|
||||
send_emails = false
|
||||
[frontend.glitch]
|
||||
# Enable the Glitch frontend integration
|
||||
enabled = false
|
||||
# Glitch assets folder
|
||||
assets = "glitch"
|
||||
# Server the assets were ripped from (and any eventual CDNs)
|
||||
server = ["https://tech.lgbt"]
|
||||
|
||||
# If send_emails is true, the following settings are required
|
||||
# [email.smtp]
|
||||
[smtp]
|
||||
# SMTP server to use for sending emails
|
||||
# server = "smtp.example.com"
|
||||
# port = 465
|
||||
# username = "test@example.com"
|
||||
# Sensitive value
|
||||
# password = "password123"
|
||||
# tls = true
|
||||
server = "smtp.example.com"
|
||||
port = 465
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
tls = true
|
||||
# Disable all email functions (this will allow people to sign up without verifying
|
||||
# their email)
|
||||
enabled = false
|
||||
|
||||
[media]
|
||||
# Can be "s3" or "local", where "local" uploads the file to the local filesystem
|
||||
# Changing this value will not retroactively apply to existing data
|
||||
# Don't forget to fill in the s3 config :3
|
||||
# If you need to change this value after setting up your instance, you must move all the files
|
||||
# from one backend to the other manually (the CLI will have an option to do this later)
|
||||
# TODO: Add CLI command to move files
|
||||
backend = "s3"
|
||||
# Whether to check the hash of media when uploading to avoid duplication
|
||||
deduplicate_media = true
|
||||
# If media backend is "local", this is the folder where the files will be stored
|
||||
# Can be any path
|
||||
uploads_path = "uploads"
|
||||
local_uploads_folder = "uploads"
|
||||
|
||||
[media.conversion]
|
||||
# Whether to automatically convert images to another format on upload
|
||||
|
|
@ -140,38 +162,39 @@ convert_images = true
|
|||
# Can be: "image/jxl", "image/webp", "image/avif", "image/png", "image/jpeg", "image/heif", "image/gif"
|
||||
# JXL support will likely not work
|
||||
convert_to = "image/webp"
|
||||
# Also convert SVG images?
|
||||
convert_vectors = false
|
||||
|
||||
# [s3]
|
||||
# Can be left commented if you don't use the S3 media backend
|
||||
# endpoint = "https://s3.example.com"
|
||||
# Sensitive value
|
||||
# Can be left blank if you don't use the S3 media backend
|
||||
# endpoint = ""
|
||||
# access_key = "XXXXX"
|
||||
# Sensitive value
|
||||
# secret_access_key = "XXX"
|
||||
# region = "us-east-1"
|
||||
# bucket_name = "versia"
|
||||
# region = ""
|
||||
# bucket_name = "lysand"
|
||||
# public_url = "https://cdn.example.com"
|
||||
# Adds a prefix to the uploaded files
|
||||
# path = "versia"
|
||||
# Use path-style URLs during upload (e.g. https://s3.example.com/versia)
|
||||
# instead of the default virtual-hosted style (e.g. https://versia.s3.example.com)
|
||||
# This is required for some S3-compatible services, such as MinIO
|
||||
# path_style = true
|
||||
|
||||
[validation]
|
||||
# Checks user data
|
||||
# Does not retroactively apply to previously entered data
|
||||
[validation.accounts]
|
||||
max_displayname_characters = 50
|
||||
max_username_characters = 30
|
||||
max_bio_characters = 5000
|
||||
max_avatar_bytes = 5_000_000
|
||||
max_header_bytes = 5_000_000
|
||||
# Regex is allowed here
|
||||
disallowed_usernames = [
|
||||
"well-known",
|
||||
max_displayname_size = 50
|
||||
max_bio_size = 160
|
||||
max_note_size = 5000
|
||||
max_avatar_size = 5_000_000
|
||||
max_header_size = 5_000_000
|
||||
max_media_size = 40_000_000
|
||||
max_media_attachments = 10
|
||||
max_media_description_size = 1000
|
||||
max_poll_options = 20
|
||||
max_poll_option_size = 500
|
||||
min_poll_duration = 60
|
||||
max_poll_duration = 1893456000
|
||||
max_username_size = 30
|
||||
max_field_count = 10
|
||||
max_field_name_size = 1000
|
||||
max_field_value_size = 1000
|
||||
# Forbidden usernames, defaults are from Akkoma
|
||||
username_blacklist = [
|
||||
".well-known",
|
||||
"~",
|
||||
"about",
|
||||
"activities",
|
||||
"api",
|
||||
|
|
@ -197,14 +220,12 @@ disallowed_usernames = [
|
|||
"search",
|
||||
"mfa",
|
||||
]
|
||||
max_field_count = 10
|
||||
max_field_name_characters = 1000
|
||||
max_field_value_characters = 1000
|
||||
max_pinned_notes = 20
|
||||
|
||||
[validation.notes]
|
||||
max_characters = 5000
|
||||
allowed_url_schemes = [
|
||||
# Whether to blacklist known temporary email providers
|
||||
blacklist_tempmail = false
|
||||
# Additional email providers to blacklist (list of domains)
|
||||
email_blacklist = []
|
||||
# Valid URL schemes, otherwise the URL is parsed as text
|
||||
url_scheme_whitelist = [
|
||||
"http",
|
||||
"https",
|
||||
"ftp",
|
||||
|
|
@ -223,71 +244,12 @@ allowed_url_schemes = [
|
|||
"ssb",
|
||||
"gemini",
|
||||
]
|
||||
max_attachments = 16
|
||||
|
||||
[validation.media]
|
||||
max_bytes = 40_000_000
|
||||
max_description_characters = 1000
|
||||
# An empty array allows all MIME types
|
||||
allowed_mime_types = []
|
||||
|
||||
[validation.emojis]
|
||||
max_bytes = 1_000_000
|
||||
max_shortcode_characters = 100
|
||||
max_description_characters = 1000
|
||||
|
||||
[validation.polls]
|
||||
max_options = 20
|
||||
max_option_characters = 500
|
||||
min_duration_seconds = 60
|
||||
# 100 days
|
||||
max_duration_seconds = 8_640_000
|
||||
|
||||
[validation.emails]
|
||||
# Blocks over 10,000 common tempmail domains
|
||||
disallow_tempmail = false
|
||||
# Regex is allowed here
|
||||
disallowed_domains = []
|
||||
|
||||
# [validation.challenges]
|
||||
# "Challenges" (aka captchas) are a way to verify that a user is human
|
||||
# Versia Server's challenges use no external services, and are proof-of-work based
|
||||
# This means that they do not require any user interaction, instead
|
||||
# they require the user's computer to do a small amount of work
|
||||
# The difficulty of the challenge, higher is will take more time to solve
|
||||
# difficulty = 50000
|
||||
# Challenge expiration time in seconds
|
||||
# expiration = 300 # 5 minutes
|
||||
# Leave this empty to generate a new key
|
||||
# Sensitive value
|
||||
# key = ""
|
||||
|
||||
# Block content that matches these regular expressions
|
||||
[validation.filters]
|
||||
note_content = [
|
||||
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
|
||||
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
|
||||
]
|
||||
emoji_shortcode = []
|
||||
username = []
|
||||
displayname = []
|
||||
bio = []
|
||||
|
||||
[notifications]
|
||||
|
||||
# Web Push Notifications configuration.
|
||||
# Leave out to disable.
|
||||
# [notifications.push]
|
||||
# Subject field embedded in the push notification
|
||||
# subject = "mailto:joe@example.com"
|
||||
#
|
||||
# [notifications.push.vapid_keys]
|
||||
# VAPID keys for push notifications
|
||||
# Run Versia Server with those values missing to generate new keys
|
||||
# Sensitive value
|
||||
# public = ""
|
||||
# Sensitive value
|
||||
# private = ""
|
||||
# Only allow those MIME types of data to be uploaded
|
||||
# This can easily be spoofed, but if it is spoofed it will appear broken
|
||||
# to normal clients until despoofed
|
||||
enforce_mime_types = false
|
||||
# Defaults to all valid MIME types
|
||||
# allowed_mime_types = []
|
||||
|
||||
[defaults]
|
||||
# Default visibility for new notes
|
||||
|
|
@ -303,46 +265,10 @@ language = "en"
|
|||
# A style name from https://www.dicebear.com/styles
|
||||
placeholder_style = "thumbs"
|
||||
|
||||
[queues]
|
||||
# Controls the delivery queue (for outbound federation)
|
||||
[queues.delivery]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the inbox processing queue (for inbound federation)
|
||||
[queues.inbox]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the fetch queue (for remote data refreshes)
|
||||
[queues.fetch]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the push queue (for push notification delivery)
|
||||
[queues.push]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the media queue (for media processing)
|
||||
[queues.media]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
[federation]
|
||||
# This is a list of domain names, such as "mastodon.social" or "pleroma.site"
|
||||
# These changes will not retroactively apply to existing data before they were changed
|
||||
# For that, please use the CLI (in a later release)
|
||||
# For that, please use the CLI
|
||||
|
||||
# These instances will not be federated with
|
||||
blocked = []
|
||||
|
|
@ -362,119 +288,73 @@ reactions = []
|
|||
banners = []
|
||||
avatars = []
|
||||
|
||||
# For bridge software, such as versia-pub/activitypub
|
||||
# Bridges must be hosted separately from the main Versia Server process
|
||||
# [federation.bridge]
|
||||
# Only versia-ap exists for now
|
||||
# software = "versia-ap"
|
||||
# For bridge software, such as lysand-org/activitypub
|
||||
# Bridges must be hosted separately from the main Lysand process
|
||||
[federation.bridge]
|
||||
enabled = false
|
||||
# Only lysand-ap exists for now
|
||||
software = "lysand-ap"
|
||||
# If this is empty, any bridge with the correct token
|
||||
# will be able to send data to your instance
|
||||
# v4, v6, ranges and wildcards are supported
|
||||
# allowed_ips = ["192.168.1.0/24"]
|
||||
allowed_ips = ["192.168.1.0/24"]
|
||||
# Token for the bridge software
|
||||
# Bridge must have the same token!
|
||||
# Sensitive value
|
||||
# token = "mycooltoken"
|
||||
# url = "https://ap.versia.social"
|
||||
token = "mycooltoken"
|
||||
|
||||
[instance]
|
||||
name = "Versia"
|
||||
description = "A Versia Server instance"
|
||||
name = "Lysand"
|
||||
description = "A Lysand instance"
|
||||
# Path to a file containing a longer description of your instance
|
||||
# This will be parsed as Markdown
|
||||
# extended_description_path = "config/description.md"
|
||||
# URL to your instance logo
|
||||
# logo = ""
|
||||
# URL to your instance banner
|
||||
# banner = ""
|
||||
|
||||
# Paths to instance long description, terms of service, and privacy policy
|
||||
# These will be parsed as Markdown
|
||||
#
|
||||
# extended_description_path = "config/extended_description.md"
|
||||
# tos_path = "config/tos.md"
|
||||
# privacy_policy_path = "config/privacy_policy.md"
|
||||
|
||||
# Primary instance languages. ISO 639-1 codes.
|
||||
languages = ["en"]
|
||||
[filters]
|
||||
# Regex filters for federated and local data
|
||||
# Drops data matching the filters
|
||||
# Does not apply retroactively to existing data
|
||||
|
||||
[instance.contact]
|
||||
# email = "staff@yourinstance.com"
|
||||
|
||||
[instance.branding]
|
||||
# logo = "https://cdn.example.com/logo.png"
|
||||
# banner = "https://cdn.example.com/banner.png"
|
||||
|
||||
# Used for federation. If left empty or missing, the server will generate one for you.
|
||||
# [instance.keys]
|
||||
# Sensitive value
|
||||
# public = ""
|
||||
# Sensitive value
|
||||
# private = ""
|
||||
|
||||
[[instance.rules]]
|
||||
# Short description of the rule
|
||||
text = "No hate speech"
|
||||
# Longer version of the rule with additional information
|
||||
hint = "Hate speech includes slurs, threats, and harassment."
|
||||
|
||||
[[instance.rules]]
|
||||
text = "No spam"
|
||||
|
||||
# [[instance.rules]]
|
||||
# ...etc
|
||||
|
||||
[permissions]
|
||||
# Control default permissions for users
|
||||
# Note that an anonymous user having a permission will not allow them
|
||||
# to do things that require authentication (e.g. 'owner:notes' -> posting a note will need
|
||||
# auth, but viewing a note will not)
|
||||
# See https://server.versia.pub/api/roles#list-of-permissions for a list of all permissions
|
||||
|
||||
# Defaults to being able to login and manage their own content
|
||||
# anonymous = []
|
||||
|
||||
# Defaults to identical to anonymous
|
||||
# default = []
|
||||
|
||||
# Defaults to being able to manage all instance data, content, and users
|
||||
# admin = []
|
||||
# Note contents
|
||||
note_content = [
|
||||
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
|
||||
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
|
||||
]
|
||||
emoji = []
|
||||
# These will drop users matching the filters
|
||||
username = []
|
||||
displayname = []
|
||||
bio = []
|
||||
|
||||
[logging]
|
||||
# Log all requests (warning: this is a lot of data)
|
||||
log_requests = false
|
||||
# Log request and their contents (warning: this is a lot of data)
|
||||
log_requests_verbose = false
|
||||
# Available levels: debug, info, warning, error, critical
|
||||
log_level = "debug"
|
||||
# For GDPR compliance, you can disable logging of IPs
|
||||
log_ip = false
|
||||
|
||||
# Available levels: trace, debug, info, warning, error, fatal
|
||||
log_level = "info" # For console output
|
||||
# Log all filtered objects
|
||||
log_filters = true
|
||||
|
||||
# [logging.file]
|
||||
# path = "logs/versia.log"
|
||||
# log_level = "info"
|
||||
#
|
||||
# [logging.file.rotation]
|
||||
# max_size = 10_000_000 # 10 MB
|
||||
# max_files = 10 # Keep 10 rotated files
|
||||
#
|
||||
# https://sentry.io support
|
||||
# [logging.sentry]
|
||||
# dsn = "https://example.com"
|
||||
# debug = false
|
||||
# sample_rate = 1.0
|
||||
# traces_sample_rate = 1.0
|
||||
# Can also be regex
|
||||
# trace_propagation_targets = []
|
||||
# max_breadcrumbs = 100
|
||||
# environment = "production"
|
||||
# log_level = "info"
|
||||
[logging.storage]
|
||||
# Path to logfile for requests
|
||||
requests = "logs/requests.log"
|
||||
|
||||
[authentication]
|
||||
# Run Versia Server with this value missing to generate a new key
|
||||
# key = ""
|
||||
[ratelimits]
|
||||
# These settings apply to every route at once
|
||||
# Amount to multiply every route's duration by
|
||||
duration_coeff = 1.0
|
||||
# Amount to multiply every route's max requests per [duration] by
|
||||
max_coeff = 1.0
|
||||
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
# Most notably, GitHub does not support this
|
||||
# Redirect URLs in your OpenID provider can be set to this:
|
||||
# <base_url>/oauth/sso/<provider_id>/callback*
|
||||
# The asterisk is important, as it allows for any query parameters to be passed
|
||||
# Authentik for example uses regex so it can be set to (regex):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
# [[authentication.openid_providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||
# url = "https://id.cpluspatch.com/application/o/versia-testing/"
|
||||
# client_id = "XXXX"
|
||||
# Sensitive value
|
||||
# client_secret = "XXXXX"
|
||||
# icon = "https://cpluspatch.com/images/icons/logo.svg"
|
||||
[custom_ratelimits]
|
||||
# Add in any API route in this style here
|
||||
# Applies before the global ratelimit changes
|
||||
# "/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
|
||||
# "/api/v1/timelines/public" = { duration = 60, max = 200 }
|
||||
|
|
|
|||
10
database/datasource.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// import { Queue } from "bullmq";
|
||||
|
||||
/* const federationQueue = new Queue("federation", {
|
||||
connection: {
|
||||
host: config.redis.queue.host,
|
||||
port: Number(config.redis.queue.port),
|
||||
password: config.redis.queue.password || undefined,
|
||||
db: config.redis.queue.database || undefined,
|
||||
},
|
||||
}); */
|
||||
36
database/entities/Application.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import type { Applications } from "~drizzle/schema";
|
||||
import type { Application as APIApplication } from "~types/mastodon/application";
|
||||
|
||||
export type Application = InferSelectModel<typeof Applications>;
|
||||
|
||||
/**
|
||||
* Retrieves the application associated with the given access token.
|
||||
* @param token The access token to retrieve the application for.
|
||||
* @returns The application associated with the given access token, or null if no such application exists.
|
||||
*/
|
||||
export const getFromToken = async (
|
||||
token: string,
|
||||
): Promise<Application | null> => {
|
||||
const result = await db.query.Tokens.findFirst({
|
||||
where: (tokens, { eq }) => eq(tokens.accessToken, token),
|
||||
with: {
|
||||
application: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result?.application || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts this application to an API application.
|
||||
* @returns The API application representation of this application.
|
||||
*/
|
||||
export const applicationToAPI = (app: Application): APIApplication => {
|
||||
return {
|
||||
name: app.name,
|
||||
website: app.website,
|
||||
vapid_key: app.vapidKey,
|
||||
};
|
||||
};
|
||||
121
database/entities/Attachment.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { EntityValidator } from "@lysand-org/federation";
|
||||
import { proxyUrl } from "@response";
|
||||
import type { Config } from "config-manager";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { MediaBackendType } from "media-manager";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Attachments } from "~drizzle/schema";
|
||||
import type { AsyncAttachment as APIAsyncAttachment } from "~types/mastodon/async_attachment";
|
||||
import type { Attachment as APIAttachment } from "~types/mastodon/attachment";
|
||||
|
||||
export type Attachment = InferSelectModel<typeof Attachments>;
|
||||
|
||||
export const attachmentToAPI = (
|
||||
attachment: Attachment,
|
||||
): APIAsyncAttachment | APIAttachment => {
|
||||
let type = "unknown";
|
||||
|
||||
if (attachment.mimeType.startsWith("image/")) {
|
||||
type = "image";
|
||||
} else if (attachment.mimeType.startsWith("video/")) {
|
||||
type = "video";
|
||||
} else if (attachment.mimeType.startsWith("audio/")) {
|
||||
type = "audio";
|
||||
}
|
||||
|
||||
return {
|
||||
id: attachment.id,
|
||||
type: type as "image" | "video" | "audio" | "unknown",
|
||||
url: proxyUrl(attachment.url) ?? "",
|
||||
remote_url: proxyUrl(attachment.remoteUrl),
|
||||
preview_url: proxyUrl(attachment.thumbnailUrl || attachment.url),
|
||||
text_url: null,
|
||||
meta: {
|
||||
width: attachment.width || undefined,
|
||||
height: attachment.height || undefined,
|
||||
fps: attachment.fps || undefined,
|
||||
size:
|
||||
attachment.width && attachment.height
|
||||
? `${attachment.width}x${attachment.height}`
|
||||
: undefined,
|
||||
duration: attachment.duration || undefined,
|
||||
length: attachment.size?.toString() || undefined,
|
||||
aspect:
|
||||
attachment.width && attachment.height
|
||||
? attachment.width / attachment.height
|
||||
: undefined,
|
||||
original: {
|
||||
width: attachment.width || undefined,
|
||||
height: attachment.height || undefined,
|
||||
size:
|
||||
attachment.width && attachment.height
|
||||
? `${attachment.width}x${attachment.height}`
|
||||
: undefined,
|
||||
aspect:
|
||||
attachment.width && attachment.height
|
||||
? attachment.width / attachment.height
|
||||
: undefined,
|
||||
},
|
||||
// Idk whether size or length is the right value
|
||||
},
|
||||
description: attachment.description,
|
||||
blurhash: attachment.blurhash,
|
||||
};
|
||||
};
|
||||
|
||||
export const attachmentToLysand = (
|
||||
attachment: Attachment,
|
||||
): typeof EntityValidator.$ContentFormat => {
|
||||
return {
|
||||
[attachment.mimeType]: {
|
||||
content: attachment.url,
|
||||
blurhash: attachment.blurhash ?? undefined,
|
||||
description: attachment.description ?? undefined,
|
||||
duration: attachment.duration ?? undefined,
|
||||
fps: attachment.fps ?? undefined,
|
||||
height: attachment.height ?? undefined,
|
||||
size: attachment.size ?? undefined,
|
||||
hash: attachment.sha256
|
||||
? {
|
||||
sha256: attachment.sha256,
|
||||
}
|
||||
: undefined,
|
||||
width: attachment.width ?? undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const attachmentFromLysand = async (
|
||||
attachmentToConvert: typeof EntityValidator.$ContentFormat,
|
||||
): Promise<InferSelectModel<typeof Attachments>> => {
|
||||
const key = Object.keys(attachmentToConvert)[0];
|
||||
const value = attachmentToConvert[key];
|
||||
|
||||
const result = await db
|
||||
.insert(Attachments)
|
||||
.values({
|
||||
mimeType: key,
|
||||
url: value.content,
|
||||
description: value.description || undefined,
|
||||
duration: value.duration || undefined,
|
||||
fps: value.fps || undefined,
|
||||
height: value.height || undefined,
|
||||
size: value.size || undefined,
|
||||
width: value.width || undefined,
|
||||
sha256: value.hash?.sha256 || undefined,
|
||||
blurhash: value.blurhash || undefined,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
};
|
||||
|
||||
export const getUrl = (name: string, config: Config) => {
|
||||
if (config.media.backend === MediaBackendType.LOCAL) {
|
||||
return new URL(`/media/${name}`, config.http.base_url).toString();
|
||||
}
|
||||
if (config.media.backend === MediaBackendType.S3) {
|
||||
return new URL(`/${name}`, config.s3.public_url).toString();
|
||||
}
|
||||
return "";
|
||||
};
|
||||
117
database/entities/Emoji.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { emojiValidatorWithColons } from "@api";
|
||||
import type { EntityValidator } from "@lysand-org/federation";
|
||||
import { proxyUrl } from "@response";
|
||||
import { type InferSelectModel, and, eq } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis, Instances } from "~drizzle/schema";
|
||||
import type { Emoji as APIEmoji } from "~types/mastodon/emoji";
|
||||
import { addInstanceIfNotExists } from "./Instance";
|
||||
|
||||
export type EmojiWithInstance = InferSelectModel<typeof Emojis> & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used for parsing emojis from local text
|
||||
* @param text The text to parse
|
||||
* @returns An array of emojis
|
||||
*/
|
||||
export const parseEmojis = async (text: string) => {
|
||||
const matches = text.match(emojiValidatorWithColons);
|
||||
if (!matches) return [];
|
||||
const emojis = await db.query.Emojis.findMany({
|
||||
where: (emoji, { eq, or }) =>
|
||||
or(
|
||||
...matches
|
||||
.map((match) => match.replace(/:/g, ""))
|
||||
.map((match) => eq(emoji.shortcode, match)),
|
||||
),
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
});
|
||||
|
||||
return emojis;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an emoji from the database, and fetches it from the remote instance if it doesn't exist.
|
||||
* @param emoji Emoji to fetch
|
||||
* @param host Host to fetch the emoji from if remote
|
||||
* @returns The emoji
|
||||
*/
|
||||
export const fetchEmoji = async (
|
||||
emojiToFetch: (typeof EntityValidator.$CustomEmojiExtension)["emojis"][0],
|
||||
host?: string,
|
||||
): Promise<EmojiWithInstance> => {
|
||||
const existingEmoji = await db
|
||||
.select()
|
||||
.from(Emojis)
|
||||
.innerJoin(Instances, eq(Emojis.instanceId, Instances.id))
|
||||
.where(
|
||||
and(
|
||||
eq(Emojis.shortcode, emojiToFetch.name),
|
||||
host ? eq(Instances.baseUrl, host) : undefined,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingEmoji[0])
|
||||
return {
|
||||
...existingEmoji[0].Emojis,
|
||||
instance: existingEmoji[0].Instances,
|
||||
};
|
||||
|
||||
const foundInstance = host ? await addInstanceIfNotExists(host) : null;
|
||||
|
||||
const result = (
|
||||
await db
|
||||
.insert(Emojis)
|
||||
.values({
|
||||
shortcode: emojiToFetch.name,
|
||||
url: Object.entries(emojiToFetch.url)[0][1].content,
|
||||
alt:
|
||||
Object.entries(emojiToFetch.url)[0][1].description ||
|
||||
undefined,
|
||||
contentType: Object.keys(emojiToFetch.url)[0],
|
||||
visibleInPicker: true,
|
||||
instanceId: foundInstance?.id,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
return {
|
||||
...result,
|
||||
instance: foundInstance,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the emoji to an APIEmoji object.
|
||||
* @returns The APIEmoji object.
|
||||
*/
|
||||
export const emojiToAPI = (emoji: EmojiWithInstance): APIEmoji => {
|
||||
return {
|
||||
// @ts-expect-error ID is not in regular Mastodon API
|
||||
id: emoji.id,
|
||||
shortcode: emoji.shortcode,
|
||||
static_url: proxyUrl(emoji.url) ?? "", // TODO: Add static version
|
||||
url: proxyUrl(emoji.url) ?? "",
|
||||
visible_in_picker: emoji.visibleInPicker,
|
||||
category: emoji.category ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const emojiToLysand = (
|
||||
emoji: EmojiWithInstance,
|
||||
): (typeof EntityValidator.$CustomEmojiExtension)["emojis"][0] => {
|
||||
return {
|
||||
name: emoji.shortcode,
|
||||
url: {
|
||||
[emoji.contentType]: {
|
||||
content: emoji.url,
|
||||
description: emoji.alt || undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
69
database/entities/Federation.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { debugRequest } from "@api";
|
||||
import {
|
||||
type EntityValidator,
|
||||
SignatureConstructor,
|
||||
} from "@lysand-org/federation";
|
||||
import { config } from "config-manager";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
import { LogLevel, LogManager } from "~packages/log-manager";
|
||||
|
||||
export const localObjectURI = (id: string) =>
|
||||
new URL(`/objects/${id}`, config.http.base_url).toString();
|
||||
|
||||
export const objectToInboxRequest = async (
|
||||
object: typeof EntityValidator.$Entity,
|
||||
author: User,
|
||||
userToSendTo: User,
|
||||
): Promise<Request> => {
|
||||
if (userToSendTo.isLocal() || !userToSendTo.getUser().endpoints?.inbox) {
|
||||
throw new Error("UserToSendTo has no inbox or is a local user");
|
||||
}
|
||||
|
||||
if (author.isRemote()) {
|
||||
throw new Error("Author is a remote user");
|
||||
}
|
||||
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(author.getUser().privateKey ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const ctor = new SignatureConstructor(privateKey, author.getUri());
|
||||
|
||||
const userInbox = new URL(userToSendTo.getUser().endpoints?.inbox ?? "");
|
||||
|
||||
const request = new Request(userInbox, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: new URL(config.http.base_url).host,
|
||||
},
|
||||
body: JSON.stringify(object),
|
||||
});
|
||||
|
||||
const { request: signed, signedString } = await ctor.sign(request);
|
||||
|
||||
if (config.debug.federation) {
|
||||
// Debug request
|
||||
await debugRequest(signed);
|
||||
|
||||
// Log public key
|
||||
new LogManager(Bun.stdout).log(
|
||||
LogLevel.DEBUG,
|
||||
"Inbox.Signature",
|
||||
`Sender public key: ${author.getUser().publicKey}`,
|
||||
);
|
||||
|
||||
// Log signed string
|
||||
new LogManager(Bun.stdout).log(
|
||||
LogLevel.DEBUG,
|
||||
"Inbox.Signature",
|
||||
`Signed string:\n${signedString}`,
|
||||
);
|
||||
}
|
||||
|
||||
return signed;
|
||||
};
|
||||
50
database/entities/Instance.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { EntityValidator } from "@lysand-org/federation";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Instances } from "~drizzle/schema";
|
||||
|
||||
/**
|
||||
* Represents an instance in the database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adds an instance to the database if it doesn't already exist.
|
||||
* @param url
|
||||
* @returns Either the database instance if it already exists, or a newly created instance.
|
||||
*/
|
||||
export const addInstanceIfNotExists = async (url: string) => {
|
||||
const origin = new URL(url).origin;
|
||||
const host = new URL(url).host;
|
||||
|
||||
const found = await db.query.Instances.findFirst({
|
||||
where: (instance, { eq }) => eq(instance.baseUrl, host),
|
||||
});
|
||||
|
||||
if (found) return found;
|
||||
|
||||
console.log(`Fetching instance metadata for ${origin}`);
|
||||
|
||||
// Fetch the instance configuration
|
||||
const metadata = (await fetch(new URL("/.well-known/lysand", origin)).then(
|
||||
(res) => res.json(),
|
||||
)) as typeof EntityValidator.$ServerMetadata;
|
||||
|
||||
if (metadata.type !== "ServerMetadata") {
|
||||
throw new Error("Invalid instance metadata (wrong type)");
|
||||
}
|
||||
|
||||
if (!(metadata.name && metadata.version)) {
|
||||
throw new Error("Invalid instance metadata (missing name or version)");
|
||||
}
|
||||
|
||||
return (
|
||||
await db
|
||||
.insert(Instances)
|
||||
.values({
|
||||
baseUrl: host,
|
||||
name: metadata.name,
|
||||
version: metadata.version,
|
||||
logo: metadata.logo,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
};
|
||||
77
database/entities/Like.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { EntityValidator } from "@lysand-org/federation";
|
||||
import { config } from "config-manager";
|
||||
import { type InferSelectModel, and, eq } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Likes, Notifications } from "~drizzle/schema";
|
||||
import type { Note } from "~packages/database-interface/note";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
|
||||
export type Like = InferSelectModel<typeof Likes>;
|
||||
|
||||
/**
|
||||
* Represents a Like entity in the database.
|
||||
*/
|
||||
export const likeToLysand = (like: Like): typeof EntityValidator.$Like => {
|
||||
return {
|
||||
id: like.id,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten
|
||||
author: (like as any).liker?.uri,
|
||||
type: "Like",
|
||||
created_at: new Date(like.createdAt).toISOString(),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten
|
||||
object: (like as any).liked?.uri,
|
||||
uri: new URL(`/objects/${like.id}`, config.http.base_url).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a like
|
||||
* @param user User liking the status
|
||||
* @param note Status being liked
|
||||
*/
|
||||
export const createLike = async (user: User, note: Note) => {
|
||||
await db.insert(Likes).values({
|
||||
likedId: note.id,
|
||||
likerId: user.id,
|
||||
});
|
||||
|
||||
if (note.getAuthor().getUser().instanceId === user.getUser().instanceId) {
|
||||
// Notify the user that their post has been favourited
|
||||
await db.insert(Notifications).values({
|
||||
accountId: user.id,
|
||||
type: "favourite",
|
||||
notifiedId: note.getAuthor().id,
|
||||
noteId: note.id,
|
||||
});
|
||||
} else {
|
||||
// TODO: Add database jobs for federating this
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a like
|
||||
* @param user User deleting their like
|
||||
* @param note Status being unliked
|
||||
*/
|
||||
export const deleteLike = async (user: User, note: Note) => {
|
||||
await db
|
||||
.delete(Likes)
|
||||
.where(and(eq(Likes.likedId, note.id), eq(Likes.likerId, user.id)));
|
||||
|
||||
// Notify the user that their post has been favourited
|
||||
await db
|
||||
.delete(Notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(Notifications.accountId, user.id),
|
||||
eq(Notifications.type, "favourite"),
|
||||
eq(Notifications.notifiedId, note.getAuthor().id),
|
||||
eq(Notifications.noteId, note.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (user.isLocal() && note.getAuthor().isRemote()) {
|
||||
// User is local, federate the delete
|
||||
// TODO: Federate this
|
||||
}
|
||||
};
|
||||
65
database/entities/Notification.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import type { Notifications } from "~drizzle/schema";
|
||||
import { Note } from "~packages/database-interface/note";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import type { Notification as APINotification } from "~types/mastodon/notification";
|
||||
import type { StatusWithRelations } from "./Status";
|
||||
import {
|
||||
type UserWithRelations,
|
||||
transformOutputToUserWithRelations,
|
||||
userExtrasTemplate,
|
||||
userRelations,
|
||||
} from "./User";
|
||||
|
||||
export type Notification = InferSelectModel<typeof Notifications>;
|
||||
|
||||
export type NotificationWithRelations = Notification & {
|
||||
status: StatusWithRelations | null;
|
||||
account: UserWithRelations;
|
||||
};
|
||||
|
||||
export const findManyNotifications = async (
|
||||
query: Parameters<typeof db.query.Notifications.findMany>[0],
|
||||
userId?: string,
|
||||
): Promise<NotificationWithRelations[]> => {
|
||||
const output = await db.query.Notifications.findMany({
|
||||
...query,
|
||||
with: {
|
||||
...query?.with,
|
||||
account: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
extras: userExtrasTemplate("Notifications_account"),
|
||||
},
|
||||
},
|
||||
extras: {
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
return await Promise.all(
|
||||
output.map(async (notif) => ({
|
||||
...notif,
|
||||
account: transformOutputToUserWithRelations(notif.account),
|
||||
status:
|
||||
(await Note.fromId(notif.noteId, userId))?.getStatus() ?? null,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
export const notificationToAPI = async (
|
||||
notification: NotificationWithRelations,
|
||||
): Promise<APINotification> => {
|
||||
const account = new User(notification.account);
|
||||
return {
|
||||
account: account.toAPI(),
|
||||
created_at: new Date(notification.createdAt).toISOString(),
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
status: notification.status
|
||||
? await Note.fromStatus(notification.status).toAPI(account)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
88
database/entities/Relationship.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Relationships } from "~drizzle/schema";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
import type { Relationship as APIRelationship } from "~types/mastodon/relationship";
|
||||
import type { UserType } from "./User";
|
||||
|
||||
export type Relationship = InferSelectModel<typeof Relationships>;
|
||||
|
||||
/**
|
||||
* Creates a new relationship between two users.
|
||||
* @param owner The user who owns the relationship.
|
||||
* @param other The user who is the subject of the relationship.
|
||||
* @returns The newly created relationship.
|
||||
*/
|
||||
export const createNewRelationship = async (
|
||||
owner: User,
|
||||
other: User,
|
||||
): Promise<Relationship> => {
|
||||
return (
|
||||
await db
|
||||
.insert(Relationships)
|
||||
.values({
|
||||
ownerId: owner.id,
|
||||
subjectId: other.id,
|
||||
languages: [],
|
||||
following: false,
|
||||
showingReblogs: false,
|
||||
notifying: false,
|
||||
followedBy: false,
|
||||
blocking: false,
|
||||
blockedBy: false,
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
requested: false,
|
||||
domainBlocking: false,
|
||||
endorsed: false,
|
||||
note: "",
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
};
|
||||
|
||||
export const checkForBidirectionalRelationships = async (
|
||||
user1: User,
|
||||
user2: User,
|
||||
createIfNotExists = true,
|
||||
): Promise<boolean> => {
|
||||
const relationship1 = await db.query.Relationships.findFirst({
|
||||
where: (rel, { and, eq }) =>
|
||||
and(eq(rel.ownerId, user1.id), eq(rel.subjectId, user2.id)),
|
||||
});
|
||||
|
||||
const relationship2 = await db.query.Relationships.findFirst({
|
||||
where: (rel, { and, eq }) =>
|
||||
and(eq(rel.ownerId, user2.id), eq(rel.subjectId, user1.id)),
|
||||
});
|
||||
|
||||
if (!relationship1 && !relationship2 && createIfNotExists) {
|
||||
await createNewRelationship(user1, user2);
|
||||
await createNewRelationship(user2, user1);
|
||||
}
|
||||
|
||||
return !!relationship1 && !!relationship2;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the relationship to an API-friendly format.
|
||||
* @returns The API-friendly relationship.
|
||||
*/
|
||||
export const relationshipToAPI = (rel: Relationship): APIRelationship => {
|
||||
return {
|
||||
blocked_by: rel.blockedBy,
|
||||
blocking: rel.blocking,
|
||||
domain_blocking: rel.domainBlocking,
|
||||
endorsed: rel.endorsed,
|
||||
followed_by: rel.followedBy,
|
||||
following: rel.following,
|
||||
id: rel.subjectId,
|
||||
muting: rel.muting,
|
||||
muting_notifications: rel.mutingNotifications,
|
||||
notifying: rel.notifying,
|
||||
requested: rel.requested,
|
||||
showing_reblogs: rel.showingReblogs,
|
||||
note: rel.note,
|
||||
};
|
||||
};
|
||||
586
database/entities/Status.ts
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
import { mentionValidator } from "@api";
|
||||
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
|
||||
import { dualLogger } from "@loggers";
|
||||
import type { EntityValidator } from "@lysand-org/federation";
|
||||
import { sanitizeHtml, sanitizeHtmlInline } from "@sanitization";
|
||||
import { config } from "config-manager";
|
||||
import {
|
||||
type InferSelectModel,
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import linkifyHtml from "linkify-html";
|
||||
import {
|
||||
anyOf,
|
||||
charIn,
|
||||
createRegExp,
|
||||
digit,
|
||||
exactly,
|
||||
global,
|
||||
letter,
|
||||
maybe,
|
||||
oneOrMore,
|
||||
} from "magic-regexp";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItAnchor from "markdown-it-anchor";
|
||||
import markdownItContainer from "markdown-it-container";
|
||||
import markdownItTocDoneRight from "markdown-it-toc-done-right";
|
||||
import { db } from "~drizzle/db";
|
||||
import { type Attachments, Instances, Notes, Users } from "~drizzle/schema";
|
||||
import { Note } from "~packages/database-interface/note";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import { LogLevel } from "~packages/log-manager";
|
||||
import type { Status as APIStatus } from "~types/mastodon/status";
|
||||
import type { Application } from "./Application";
|
||||
import { attachmentFromLysand } from "./Attachment";
|
||||
import { type EmojiWithInstance, fetchEmoji } from "./Emoji";
|
||||
import { objectToInboxRequest } from "./Federation";
|
||||
import {
|
||||
type UserWithInstance,
|
||||
type UserWithRelations,
|
||||
resolveWebFinger,
|
||||
transformOutputToUserWithRelations,
|
||||
userExtrasTemplate,
|
||||
userRelations,
|
||||
} from "./User";
|
||||
|
||||
export type Status = InferSelectModel<typeof Notes>;
|
||||
|
||||
export type StatusWithRelations = Status & {
|
||||
author: UserWithRelations;
|
||||
mentions: UserWithInstance[];
|
||||
attachments: InferSelectModel<typeof Attachments>[];
|
||||
reblog: StatusWithoutRecursiveRelations | null;
|
||||
emojis: EmojiWithInstance[];
|
||||
reply: Status | null;
|
||||
quote: Status | null;
|
||||
application: Application | null;
|
||||
reblogCount: number;
|
||||
likeCount: number;
|
||||
replyCount: number;
|
||||
pinned: boolean;
|
||||
reblogged: boolean;
|
||||
muted: boolean;
|
||||
liked: boolean;
|
||||
};
|
||||
|
||||
export type StatusWithoutRecursiveRelations = Omit<
|
||||
StatusWithRelations,
|
||||
"reply" | "quote" | "reblog"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Wrapper against the Status object to make it easier to work with
|
||||
* @param query
|
||||
* @returns
|
||||
*/
|
||||
export const findManyNotes = async (
|
||||
query: Parameters<typeof db.query.Notes.findMany>[0],
|
||||
userId?: string,
|
||||
): Promise<StatusWithRelations[]> => {
|
||||
const output = await db.query.Notes.findMany({
|
||||
...query,
|
||||
with: {
|
||||
...query?.with,
|
||||
attachments: true,
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
extras: userExtrasTemplate("Notes_author"),
|
||||
},
|
||||
mentions: {
|
||||
with: {
|
||||
user: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
reblog: {
|
||||
with: {
|
||||
attachments: true,
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
likes: true,
|
||||
application: true,
|
||||
mentions: {
|
||||
with: {
|
||||
user: {
|
||||
with: userRelations,
|
||||
extras: userExtrasTemplate(
|
||||
"Notes_reblog_mentions_user",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
extras: userExtrasTemplate("Notes_reblog_author"),
|
||||
},
|
||||
},
|
||||
extras: {
|
||||
reblogCount:
|
||||
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."reblogId" = "Notes_reblog".id)`.as(
|
||||
"reblog_count",
|
||||
),
|
||||
likeCount:
|
||||
sql`(SELECT COUNT(*) FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id)`.as(
|
||||
"like_count",
|
||||
),
|
||||
replyCount:
|
||||
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes_reblog".id)`.as(
|
||||
"reply_count",
|
||||
),
|
||||
pinned: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes_reblog".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
|
||||
"pinned",
|
||||
)
|
||||
: sql`false`.as("pinned"),
|
||||
reblogged: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes_reblog".id)`.as(
|
||||
"reblogged",
|
||||
)
|
||||
: sql`false`.as("reblogged"),
|
||||
muted: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes_reblog"."authorId" AND "Relationships"."muting" = true)`.as(
|
||||
"muted",
|
||||
)
|
||||
: sql`false`.as("muted"),
|
||||
liked: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id AND "Likes"."likerId" = ${userId})`.as(
|
||||
"liked",
|
||||
)
|
||||
: sql`false`.as("liked"),
|
||||
},
|
||||
},
|
||||
reply: true,
|
||||
quote: true,
|
||||
},
|
||||
extras: {
|
||||
reblogCount:
|
||||
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."reblogId" = "Notes".id)`.as(
|
||||
"reblog_count",
|
||||
),
|
||||
likeCount:
|
||||
sql`(SELECT COUNT(*) FROM "Likes" WHERE "Likes"."likedId" = "Notes".id)`.as(
|
||||
"like_count",
|
||||
),
|
||||
replyCount:
|
||||
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes".id)`.as(
|
||||
"reply_count",
|
||||
),
|
||||
pinned: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
|
||||
"pinned",
|
||||
)
|
||||
: sql`false`.as("pinned"),
|
||||
reblogged: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes".id)`.as(
|
||||
"reblogged",
|
||||
)
|
||||
: sql`false`.as("reblogged"),
|
||||
muted: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes"."authorId" AND "Relationships"."muting" = true)`.as(
|
||||
"muted",
|
||||
)
|
||||
: sql`false`.as("muted"),
|
||||
liked: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes".id AND "Likes"."likerId" = ${userId})`.as(
|
||||
"liked",
|
||||
)
|
||||
: sql`false`.as("liked"),
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
return output.map((post) => ({
|
||||
...post,
|
||||
author: transformOutputToUserWithRelations(post.author),
|
||||
mentions: post.mentions.map((mention) => ({
|
||||
...mention.user,
|
||||
endpoints: mention.user.endpoints,
|
||||
})),
|
||||
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
|
||||
reblog: post.reblog && {
|
||||
...post.reblog,
|
||||
author: transformOutputToUserWithRelations(post.reblog.author),
|
||||
mentions: post.reblog.mentions.map((mention) => ({
|
||||
...mention.user,
|
||||
endpoints: mention.user.endpoints,
|
||||
})),
|
||||
emojis: (post.reblog.emojis ?? []).map((emoji) => emoji.emoji),
|
||||
reblogCount: Number(post.reblog.reblogCount),
|
||||
likeCount: Number(post.reblog.likeCount),
|
||||
replyCount: Number(post.reblog.replyCount),
|
||||
pinned: Boolean(post.reblog.pinned),
|
||||
reblogged: Boolean(post.reblog.reblogged),
|
||||
muted: Boolean(post.reblog.muted),
|
||||
liked: Boolean(post.reblog.liked),
|
||||
},
|
||||
reblogCount: Number(post.reblogCount),
|
||||
likeCount: Number(post.likeCount),
|
||||
replyCount: Number(post.replyCount),
|
||||
pinned: Boolean(post.pinned),
|
||||
reblogged: Boolean(post.reblogged),
|
||||
muted: Boolean(post.muted),
|
||||
liked: Boolean(post.liked),
|
||||
}));
|
||||
};
|
||||
|
||||
export const resolveNote = async (
|
||||
uri?: string,
|
||||
providedNote?: typeof EntityValidator.$Note,
|
||||
): Promise<Note> => {
|
||||
if (!uri && !providedNote) {
|
||||
throw new Error("No URI or note provided");
|
||||
}
|
||||
|
||||
const foundStatus = await Note.fromSql(
|
||||
eq(Notes.uri, uri ?? providedNote?.uri ?? ""),
|
||||
);
|
||||
|
||||
if (foundStatus) return foundStatus;
|
||||
|
||||
let note = providedNote ?? null;
|
||||
|
||||
if (uri) {
|
||||
if (!URL.canParse(uri)) {
|
||||
throw new Error(`Invalid URI to parse ${uri}`);
|
||||
}
|
||||
|
||||
const response = await fetch(uri, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
note = (await response.json()) as typeof EntityValidator.$Note;
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
throw new Error("No note was able to be fetched");
|
||||
}
|
||||
|
||||
if (note.type !== "Note") {
|
||||
throw new Error("Invalid object type");
|
||||
}
|
||||
|
||||
if (!note.author) {
|
||||
throw new Error("Invalid object author");
|
||||
}
|
||||
|
||||
const author = await User.resolve(note.author);
|
||||
|
||||
if (!author) {
|
||||
throw new Error("Invalid object author");
|
||||
}
|
||||
|
||||
const attachments = [];
|
||||
|
||||
for (const attachment of note.attachments ?? []) {
|
||||
const resolvedAttachment = await attachmentFromLysand(attachment).catch(
|
||||
(e) => {
|
||||
dualLogger.logError(
|
||||
LogLevel.ERROR,
|
||||
"Federation.StatusResolver",
|
||||
e,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (resolvedAttachment) {
|
||||
attachments.push(resolvedAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
const emojis = [];
|
||||
|
||||
for (const emoji of note.extensions?.["org.lysand:custom_emojis"]?.emojis ??
|
||||
[]) {
|
||||
const resolvedEmoji = await fetchEmoji(emoji).catch((e) => {
|
||||
dualLogger.logError(LogLevel.ERROR, "Federation.StatusResolver", e);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (resolvedEmoji) {
|
||||
emojis.push(resolvedEmoji);
|
||||
}
|
||||
}
|
||||
|
||||
const createdNote = await Note.fromData(
|
||||
author,
|
||||
note.content ?? {
|
||||
"text/plain": {
|
||||
content: "",
|
||||
},
|
||||
},
|
||||
note.visibility as APIStatus["visibility"],
|
||||
note.is_sensitive ?? false,
|
||||
note.subject ?? "",
|
||||
emojis,
|
||||
note.uri,
|
||||
await Promise.all(
|
||||
(note.mentions ?? [])
|
||||
.map((mention) => User.resolve(mention))
|
||||
.filter((mention) => mention !== null) as Promise<User>[],
|
||||
),
|
||||
attachments.map((a) => a.id),
|
||||
note.replies_to
|
||||
? (await resolveNote(note.replies_to)).getStatus().id
|
||||
: undefined,
|
||||
note.quotes
|
||||
? (await resolveNote(note.quotes)).getStatus().id
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (!createdNote) {
|
||||
throw new Error("Failed to create status");
|
||||
}
|
||||
|
||||
return createdNote;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get people mentioned in the content (match @username or @username@domain.com mentions)
|
||||
* @param text The text to parse mentions from.
|
||||
* @returns An array of users mentioned in the text.
|
||||
*/
|
||||
export const parseTextMentions = async (text: string): Promise<User[]> => {
|
||||
const mentionedPeople = [...text.matchAll(mentionValidator)] ?? [];
|
||||
if (mentionedPeople.length === 0) return [];
|
||||
|
||||
const baseUrlHost = new URL(config.http.base_url).host;
|
||||
|
||||
const isLocal = (host?: string) => host === baseUrlHost || !host;
|
||||
|
||||
const foundUsers = await db
|
||||
.select({
|
||||
id: Users.id,
|
||||
username: Users.username,
|
||||
baseUrl: Instances.baseUrl,
|
||||
})
|
||||
.from(Users)
|
||||
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
|
||||
.where(
|
||||
or(
|
||||
...mentionedPeople.map((person) =>
|
||||
and(
|
||||
eq(Users.username, person?.[1] ?? ""),
|
||||
isLocal(person?.[2])
|
||||
? isNull(Users.instanceId)
|
||||
: eq(Instances.baseUrl, person?.[2] ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const notFoundRemoteUsers = mentionedPeople.filter(
|
||||
(person) =>
|
||||
!isLocal(person?.[2]) &&
|
||||
!foundUsers.find(
|
||||
(user) =>
|
||||
user.username === person?.[1] &&
|
||||
user.baseUrl === person?.[2],
|
||||
),
|
||||
);
|
||||
|
||||
const finalList =
|
||||
foundUsers.length > 0
|
||||
? await User.manyFromSql(
|
||||
inArray(
|
||||
Users.id,
|
||||
foundUsers.map((u) => u.id),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
// Attempt to resolve mentions that were not found
|
||||
for (const person of notFoundRemoteUsers) {
|
||||
const user = await resolveWebFinger(
|
||||
person?.[1] ?? "",
|
||||
person?.[2] ?? "",
|
||||
);
|
||||
|
||||
if (user) {
|
||||
finalList.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
return finalList;
|
||||
};
|
||||
|
||||
export const replaceTextMentions = async (text: string, mentions: User[]) => {
|
||||
let finalText = text;
|
||||
for (const mention of mentions) {
|
||||
const user = mention.getUser();
|
||||
// Replace @username and @username@domain
|
||||
if (user.instance) {
|
||||
finalText = finalText.replace(
|
||||
createRegExp(
|
||||
exactly(`@${user.username}@${user.instance.baseUrl}`),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}@${user.instance.baseUrl}</a>`,
|
||||
);
|
||||
} else {
|
||||
finalText = finalText.replace(
|
||||
// Only replace @username if it doesn't have another @ right after
|
||||
createRegExp(
|
||||
exactly(`@${user.username}`)
|
||||
.notBefore(anyOf(letter, digit, charIn("@")))
|
||||
.notAfter(anyOf(letter, digit, charIn("@"))),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}</a>`,
|
||||
);
|
||||
|
||||
finalText = finalText.replace(
|
||||
createRegExp(
|
||||
exactly(
|
||||
`@${user.username}@${
|
||||
new URL(config.http.base_url).host
|
||||
}`,
|
||||
),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}</a>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return finalText;
|
||||
};
|
||||
|
||||
export const contentToHtml = async (
|
||||
content: typeof EntityValidator.$ContentFormat,
|
||||
mentions: User[] = [],
|
||||
inline = false,
|
||||
): Promise<string> => {
|
||||
let htmlContent: string;
|
||||
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
|
||||
|
||||
if (content["text/html"]) {
|
||||
htmlContent = await sanitizer(content["text/html"].content);
|
||||
} else if (content["text/markdown"]) {
|
||||
htmlContent = await sanitizer(
|
||||
await markdownParse(content["text/markdown"].content),
|
||||
);
|
||||
} else if (content["text/plain"]?.content) {
|
||||
// Split by newline and add <p> tags
|
||||
htmlContent = (await sanitizer(content["text/plain"].content))
|
||||
.split("\n")
|
||||
.map((line) => `<p>${line}</p>`)
|
||||
.join("\n");
|
||||
} else {
|
||||
htmlContent = "";
|
||||
}
|
||||
|
||||
// Replace mentions text
|
||||
htmlContent = await replaceTextMentions(htmlContent, mentions ?? []);
|
||||
|
||||
// Linkify
|
||||
htmlContent = linkifyHtml(htmlContent, {
|
||||
defaultProtocol: "https",
|
||||
validate: {
|
||||
email: () => false,
|
||||
},
|
||||
target: "_blank",
|
||||
rel: "nofollow noopener noreferrer",
|
||||
});
|
||||
|
||||
return htmlContent;
|
||||
};
|
||||
|
||||
export const markdownParse = async (content: string) => {
|
||||
return (await getMarkdownRenderer()).render(content);
|
||||
};
|
||||
|
||||
export const getMarkdownRenderer = async () => {
|
||||
const renderer = MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
renderer.use(markdownItAnchor, {
|
||||
permalink: markdownItAnchor.permalink.ariaHidden({
|
||||
symbol: "",
|
||||
placement: "before",
|
||||
}),
|
||||
});
|
||||
|
||||
renderer.use(markdownItTocDoneRight, {
|
||||
containerClass: "toc",
|
||||
level: [1, 2, 3, 4],
|
||||
listType: "ul",
|
||||
listClass: "toc-list",
|
||||
itemClass: "toc-item",
|
||||
linkClass: "toc-link",
|
||||
});
|
||||
|
||||
renderer.use(markdownItTaskLists);
|
||||
|
||||
renderer.use(markdownItContainer);
|
||||
|
||||
return renderer;
|
||||
};
|
||||
|
||||
export const federateNote = async (note: Note) => {
|
||||
for (const user of await note.getUsersToFederateTo()) {
|
||||
// TODO: Add queue system
|
||||
const request = await objectToInboxRequest(
|
||||
note.toLysand(),
|
||||
note.getAuthor(),
|
||||
user,
|
||||
);
|
||||
|
||||
// Send request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
dualLogger.log(
|
||||
LogLevel.DEBUG,
|
||||
"Federation.Status",
|
||||
await response.text(),
|
||||
);
|
||||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.Status",
|
||||
`Failed to federate status ${
|
||||
note.getStatus().id
|
||||
} to ${user.getUri()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
11
database/entities/Token.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { Tokens } from "~drizzle/schema";
|
||||
|
||||
/**
|
||||
* The type of token.
|
||||
*/
|
||||
export enum TokenType {
|
||||
BEARER = "Bearer",
|
||||
}
|
||||
|
||||
export type Token = InferSelectModel<typeof Tokens>;
|
||||
525
database/entities/User.ts
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
import { dualLogger } from "@loggers";
|
||||
import type { EntityValidator } from "@lysand-org/federation";
|
||||
import { config } from "config-manager";
|
||||
import { type InferSelectModel, and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import {
|
||||
Applications,
|
||||
Instances,
|
||||
Notifications,
|
||||
Relationships,
|
||||
Tokens,
|
||||
Users,
|
||||
} from "~drizzle/schema";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import { LogLevel } from "~packages/log-manager";
|
||||
import type { Application } from "./Application";
|
||||
import type { EmojiWithInstance } from "./Emoji";
|
||||
import { objectToInboxRequest } from "./Federation";
|
||||
import { createNewRelationship } from "./Relationship";
|
||||
import type { Token } from "./Token";
|
||||
|
||||
export type UserType = InferSelectModel<typeof Users>;
|
||||
|
||||
export type UserWithInstance = UserType & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
};
|
||||
|
||||
export type UserWithRelations = UserType & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
emojis: EmojiWithInstance[];
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
statusCount: number;
|
||||
};
|
||||
|
||||
export const userRelations: {
|
||||
instance: true;
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} = {
|
||||
instance: true,
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const userExtras = {
|
||||
followerCount:
|
||||
sql`(SELECT COUNT(*) FROM "Relationships" "relationships" WHERE ("relationships"."ownerId" = "Users".id AND "relationships"."following" = true))`.as(
|
||||
"follower_count",
|
||||
),
|
||||
followingCount:
|
||||
sql`(SELECT COUNT(*) FROM "Relationships" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "Users".id AND "relationshipSubjects"."following" = true))`.as(
|
||||
"following_count",
|
||||
),
|
||||
statusCount:
|
||||
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."authorId" = "Users".id)`.as(
|
||||
"status_count",
|
||||
),
|
||||
};
|
||||
|
||||
export const userExtrasTemplate = (name: string) => ({
|
||||
// @ts-ignore
|
||||
followerCount: sql([
|
||||
`(SELECT COUNT(*) FROM "Relationships" "relationships" WHERE ("relationships"."ownerId" = "${name}".id AND "relationships"."following" = true))`,
|
||||
]).as("follower_count"),
|
||||
// @ts-ignore
|
||||
followingCount: sql([
|
||||
`(SELECT COUNT(*) FROM "Relationships" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "${name}".id AND "relationshipSubjects"."following" = true))`,
|
||||
]).as("following_count"),
|
||||
// @ts-ignore
|
||||
statusCount: sql([
|
||||
`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."authorId" = "${name}".id)`,
|
||||
]).as("status_count"),
|
||||
});
|
||||
|
||||
export interface AuthData {
|
||||
user: User | null;
|
||||
token: string;
|
||||
application: Application | null;
|
||||
}
|
||||
|
||||
export const getFromHeader = async (value: string): Promise<AuthData> => {
|
||||
const token = value.split(" ")[1];
|
||||
|
||||
const { user, application } =
|
||||
await retrieveUserAndApplicationFromToken(token);
|
||||
|
||||
return { user, token, application };
|
||||
};
|
||||
|
||||
export const followRequestUser = async (
|
||||
follower: User,
|
||||
followee: User,
|
||||
relationshipId: string,
|
||||
reblogs = false,
|
||||
notify = false,
|
||||
languages: string[] = [],
|
||||
): Promise<InferSelectModel<typeof Relationships>> => {
|
||||
const isRemote = followee.isRemote();
|
||||
|
||||
const updatedRelationship = (
|
||||
await db
|
||||
.update(Relationships)
|
||||
.set({
|
||||
following: isRemote ? false : !followee.getUser().isLocked,
|
||||
requested: isRemote ? true : followee.getUser().isLocked,
|
||||
showingReblogs: reblogs,
|
||||
notifying: notify,
|
||||
languages: languages,
|
||||
})
|
||||
.where(eq(Relationships.id, relationshipId))
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
if (isRemote) {
|
||||
// Federate
|
||||
// TODO: Make database job
|
||||
const request = await objectToInboxRequest(
|
||||
followRequestToLysand(follower, followee),
|
||||
follower,
|
||||
followee,
|
||||
);
|
||||
|
||||
// Send request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
dualLogger.log(
|
||||
LogLevel.DEBUG,
|
||||
"Federation.FollowRequest",
|
||||
await response.text(),
|
||||
);
|
||||
|
||||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowRequest",
|
||||
`Failed to federate follow request from ${
|
||||
follower.id
|
||||
} to ${followee.getUri()}`,
|
||||
);
|
||||
|
||||
return (
|
||||
await db
|
||||
.update(Relationships)
|
||||
.set({
|
||||
following: false,
|
||||
requested: false,
|
||||
})
|
||||
.where(eq(Relationships.id, relationshipId))
|
||||
.returning()
|
||||
)[0];
|
||||
}
|
||||
} else {
|
||||
await db.insert(Notifications).values({
|
||||
accountId: follower.id,
|
||||
type: followee.getUser().isLocked ? "follow_request" : "follow",
|
||||
notifiedId: followee.id,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedRelationship;
|
||||
};
|
||||
|
||||
export const sendFollowAccept = async (follower: User, followee: User) => {
|
||||
// TODO: Make database job
|
||||
const request = await objectToInboxRequest(
|
||||
followAcceptToLysand(follower, followee),
|
||||
followee,
|
||||
follower,
|
||||
);
|
||||
|
||||
// Send request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
dualLogger.log(
|
||||
LogLevel.DEBUG,
|
||||
"Federation.FollowAccept",
|
||||
await response.text(),
|
||||
);
|
||||
|
||||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowAccept",
|
||||
`Failed to federate follow accept from ${
|
||||
followee.id
|
||||
} to ${follower.getUri()}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendFollowReject = async (follower: User, followee: User) => {
|
||||
// TODO: Make database job
|
||||
const request = await objectToInboxRequest(
|
||||
followRejectToLysand(follower, followee),
|
||||
followee,
|
||||
follower,
|
||||
);
|
||||
|
||||
// Send request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
dualLogger.log(
|
||||
LogLevel.DEBUG,
|
||||
"Federation.FollowReject",
|
||||
await response.text(),
|
||||
);
|
||||
|
||||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowReject",
|
||||
`Failed to federate follow reject from ${
|
||||
followee.id
|
||||
} to ${follower.getUri()}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const transformOutputToUserWithRelations = (
|
||||
user: Omit<UserType, "endpoints"> & {
|
||||
followerCount: unknown;
|
||||
followingCount: unknown;
|
||||
statusCount: unknown;
|
||||
emojis: {
|
||||
userId: string;
|
||||
emojiId: string;
|
||||
emoji?: EmojiWithInstance;
|
||||
}[];
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
endpoints: unknown;
|
||||
},
|
||||
): UserWithRelations => {
|
||||
return {
|
||||
...user,
|
||||
followerCount: Number(user.followerCount),
|
||||
followingCount: Number(user.followingCount),
|
||||
statusCount: Number(user.statusCount),
|
||||
endpoints:
|
||||
user.endpoints ??
|
||||
({} as Partial<{
|
||||
dislikes: string;
|
||||
featured: string;
|
||||
likes: string;
|
||||
followers: string;
|
||||
following: string;
|
||||
inbox: string;
|
||||
outbox: string;
|
||||
}>),
|
||||
emojis: user.emojis.map(
|
||||
(emoji) =>
|
||||
(emoji as unknown as Record<string, object>)
|
||||
.emoji as EmojiWithInstance,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const findManyUsers = async (
|
||||
query: Parameters<typeof db.query.Users.findMany>[0],
|
||||
): Promise<UserWithRelations[]> => {
|
||||
const output = await db.query.Users.findMany({
|
||||
...query,
|
||||
with: {
|
||||
...userRelations,
|
||||
...query?.with,
|
||||
},
|
||||
extras: {
|
||||
...userExtras,
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
return output.map((user) => transformOutputToUserWithRelations(user));
|
||||
};
|
||||
|
||||
export const findFirstUser = async (
|
||||
query: Parameters<typeof db.query.Users.findFirst>[0],
|
||||
): Promise<UserWithRelations | null> => {
|
||||
const output = await db.query.Users.findFirst({
|
||||
...query,
|
||||
with: {
|
||||
...userRelations,
|
||||
...query?.with,
|
||||
},
|
||||
extras: {
|
||||
...userExtras,
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
if (!output) return null;
|
||||
|
||||
return transformOutputToUserWithRelations(output);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a WebFinger identifier to a user.
|
||||
* @param identifier Either a UUID or a username
|
||||
*/
|
||||
export const resolveWebFinger = async (
|
||||
identifier: string,
|
||||
host: string,
|
||||
): Promise<User | null> => {
|
||||
// Check if user not already in database
|
||||
const foundUser = await db
|
||||
.select()
|
||||
.from(Users)
|
||||
.innerJoin(Instances, eq(Users.instanceId, Instances.id))
|
||||
.where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host)))
|
||||
.limit(1);
|
||||
|
||||
if (foundUser[0]) return await User.fromId(foundUser[0].Users.id);
|
||||
|
||||
const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`;
|
||||
|
||||
const response = await fetch(
|
||||
new URL(
|
||||
`/.well-known/webfinger?${new URLSearchParams({
|
||||
resource: `acct:${identifier}@${host}`,
|
||||
})}`,
|
||||
hostWithProtocol,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
subject: string;
|
||||
links: {
|
||||
rel: string;
|
||||
type: string;
|
||||
href: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
if (!data.subject || !data.links) {
|
||||
throw new Error(
|
||||
"Invalid WebFinger data (missing subject or links from response)",
|
||||
);
|
||||
}
|
||||
|
||||
const relevantLink = data.links.find((link) => link.rel === "self");
|
||||
|
||||
if (!relevantLink) {
|
||||
throw new Error(
|
||||
"Invalid WebFinger data (missing link with rel: 'self')",
|
||||
);
|
||||
}
|
||||
|
||||
return User.resolve(relevantLink.href);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a user from a token.
|
||||
* @param access_token The access token to retrieve the user from.
|
||||
* @returns The user associated with the given access token.
|
||||
*/
|
||||
export const retrieveUserFromToken = async (
|
||||
access_token: string,
|
||||
): Promise<User | null> => {
|
||||
if (!access_token) return null;
|
||||
|
||||
const token = await retrieveToken(access_token);
|
||||
|
||||
if (!token || !token.userId) return null;
|
||||
|
||||
const user = await User.fromId(token.userId);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const retrieveUserAndApplicationFromToken = async (
|
||||
access_token: string,
|
||||
): Promise<{
|
||||
user: User | null;
|
||||
application: Application | null;
|
||||
}> => {
|
||||
if (!access_token) return { user: null, application: null };
|
||||
|
||||
const output = (
|
||||
await db
|
||||
.select({
|
||||
token: Tokens,
|
||||
application: Applications,
|
||||
})
|
||||
.from(Tokens)
|
||||
.leftJoin(Applications, eq(Tokens.applicationId, Applications.id))
|
||||
.where(eq(Tokens.accessToken, access_token))
|
||||
.limit(1)
|
||||
)[0];
|
||||
|
||||
if (!output?.token.userId) return { user: null, application: null };
|
||||
|
||||
const user = await User.fromId(output.token.userId);
|
||||
|
||||
return { user, application: output.application ?? null };
|
||||
};
|
||||
|
||||
export const retrieveToken = async (
|
||||
access_token: string,
|
||||
): Promise<Token | null> => {
|
||||
if (!access_token) return null;
|
||||
|
||||
return (
|
||||
(await db.query.Tokens.findFirst({
|
||||
where: (tokens, { eq }) => eq(tokens.accessToken, access_token),
|
||||
})) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the relationship to another user.
|
||||
* @param other The other user to get the relationship to.
|
||||
* @returns The relationship to the other user.
|
||||
*/
|
||||
export const getRelationshipToOtherUser = async (
|
||||
user: User,
|
||||
other: User,
|
||||
): Promise<InferSelectModel<typeof Relationships>> => {
|
||||
const foundRelationship = await db.query.Relationships.findFirst({
|
||||
where: (relationship, { and, eq }) =>
|
||||
and(
|
||||
eq(relationship.ownerId, user.id),
|
||||
eq(relationship.subjectId, other.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!foundRelationship) {
|
||||
// Create new relationship
|
||||
|
||||
const newRelationship = await createNewRelationship(user, other);
|
||||
|
||||
return newRelationship;
|
||||
}
|
||||
|
||||
return foundRelationship;
|
||||
};
|
||||
|
||||
export const followRequestToLysand = (
|
||||
follower: User,
|
||||
followee: User,
|
||||
): typeof EntityValidator.$Follow => {
|
||||
if (follower.isRemote()) {
|
||||
throw new Error("Follower must be a local user");
|
||||
}
|
||||
|
||||
if (!followee.isRemote()) {
|
||||
throw new Error("Followee must be a remote user");
|
||||
}
|
||||
|
||||
if (!followee.getUser().uri) {
|
||||
throw new Error("Followee must have a URI in database");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
return {
|
||||
type: "Follow",
|
||||
id: id,
|
||||
author: follower.getUri(),
|
||||
followee: followee.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const followAcceptToLysand = (
|
||||
follower: User,
|
||||
followee: User,
|
||||
): typeof EntityValidator.$FollowAccept => {
|
||||
if (!follower.isRemote()) {
|
||||
throw new Error("Follower must be a remote user");
|
||||
}
|
||||
|
||||
if (followee.isRemote()) {
|
||||
throw new Error("Followee must be a local user");
|
||||
}
|
||||
|
||||
if (!follower.getUser().uri) {
|
||||
throw new Error("Follower must have a URI in database");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
return {
|
||||
type: "FollowAccept",
|
||||
id: id,
|
||||
author: followee.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
follower: follower.getUri(),
|
||||
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const followRejectToLysand = (
|
||||
follower: User,
|
||||
followee: User,
|
||||
): typeof EntityValidator.$FollowReject => {
|
||||
return {
|
||||
...followAcceptToLysand(follower, followee),
|
||||
type: "FollowReject",
|
||||
};
|
||||
};
|
||||
|
|
@ -1,65 +1,64 @@
|
|||
services:
|
||||
versia:
|
||||
image: ghcr.io/versia-pub/server:main
|
||||
lysand:
|
||||
build: ghcr.io/lysand-org/lysand:latest
|
||||
volumes:
|
||||
- ./logs:/app/dist/logs
|
||||
- ./config:/app/dist/config:ro
|
||||
- ./config:/app/dist/config
|
||||
- ./uploads:/app/dist/uploads
|
||||
- ./glitch:/app/dist/glitch
|
||||
restart: unless-stopped
|
||||
container_name: versia
|
||||
tty: true
|
||||
container_name: lysand
|
||||
networks:
|
||||
- versia-net
|
||||
depends_on:
|
||||
- lysand-net
|
||||
depends-on:
|
||||
- db
|
||||
- redis
|
||||
- sonic
|
||||
|
||||
worker:
|
||||
image: ghcr.io/versia-pub/worker:main
|
||||
volumes:
|
||||
- ./logs:/app/dist/logs
|
||||
- ./config:/app/dist/config:ro
|
||||
- meilisearch
|
||||
- fe
|
||||
|
||||
fe:
|
||||
image: ghcr.io/lysand-org/lysand-fe:main
|
||||
container_name: lysand-fe
|
||||
restart: unless-stopped
|
||||
container_name: versia-worker
|
||||
tty: true
|
||||
networks:
|
||||
- versia-net
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
- lysand-net
|
||||
environment:
|
||||
NUXT_PUBLIC_API_HOST: https://yourserver.com
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: versia-db
|
||||
image: ghcr.io/lysand-org/postgres:main
|
||||
container_name: lysand-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: versia
|
||||
POSTGRES_USER: versia
|
||||
POSTGRES_PASSWORD: versia
|
||||
POSTGRES_DB: lysand
|
||||
POSTGRES_USER: lysand
|
||||
POSTGRES_PASSWORD: _______________
|
||||
networks:
|
||||
- versia-net
|
||||
- lysand-net
|
||||
volumes:
|
||||
- ./db-data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: versia-redis
|
||||
container_name: lysand-redis
|
||||
volumes:
|
||||
- ./redis-data:/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- versia-net
|
||||
- lysand-net
|
||||
|
||||
sonic:
|
||||
volumes:
|
||||
- ./config.cfg:/etc/sonic.cfg
|
||||
- ./store:/var/lib/sonic/store/
|
||||
image: valeriansaliou/sonic:v1.4.9
|
||||
container_name: versia-sonic
|
||||
restart: unless-stopped
|
||||
meilisearch:
|
||||
stdin_open: true
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=__________________
|
||||
tty: true
|
||||
networks:
|
||||
- versia-net
|
||||
- lysand-net
|
||||
volumes:
|
||||
- ./meili-data:/meili_data
|
||||
image: getmeili/meilisearch:v1.7
|
||||
container_name: lysand-meilisearch
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
versia-net:
|
||||
lysand-net:
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import taskLists from "@hackmd/markdown-it-task-lists";
|
||||
import implicitFigures from "markdown-it-image-figures";
|
||||
import { defineConfig } from "vitepress";
|
||||
import { tabsMarkdownPlugin } from "vitepress-plugin-tabs";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "Versia Server Docs",
|
||||
lang: "en-US",
|
||||
description: "Documentation for Versia Server APIs",
|
||||
markdown: {
|
||||
config: (md): void => {
|
||||
md.use(implicitFigures, {
|
||||
figcaption: "alt",
|
||||
copyAttrs: "^class$",
|
||||
});
|
||||
|
||||
md.use(taskLists);
|
||||
|
||||
md.use(tabsMarkdownPlugin);
|
||||
},
|
||||
math: true,
|
||||
},
|
||||
cleanUrls: true,
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{
|
||||
text: "Versia Protocol",
|
||||
link: "https://versia.pub",
|
||||
target: "_blank",
|
||||
},
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: "Installation",
|
||||
items: [
|
||||
{
|
||||
text: "Normal",
|
||||
link: "/setup/installation",
|
||||
},
|
||||
{
|
||||
text: "Nix",
|
||||
link: "/setup/nix",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "CLI",
|
||||
link: "/cli",
|
||||
},
|
||||
{
|
||||
text: "API",
|
||||
items: [
|
||||
{
|
||||
text: "Reactions",
|
||||
link: "/api/reactions",
|
||||
},
|
||||
{
|
||||
text: "Challenges",
|
||||
link: "/api/challenges",
|
||||
},
|
||||
{
|
||||
text: "Mastodon Extensions",
|
||||
link: "/api/mastodon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Frontend",
|
||||
items: [
|
||||
{
|
||||
text: "Authentication",
|
||||
link: "/frontend/auth",
|
||||
},
|
||||
{
|
||||
text: "Routes",
|
||||
link: "/frontend/routes",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/versia-pub/server" },
|
||||
],
|
||||
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
|
||||
logo: "https://cdn.versia.pub/branding/icon.svg",
|
||||
},
|
||||
head: [["link", { rel: "icon", href: "/favicon.png", type: "image/png" }]],
|
||||
titleTemplate: ":title • Versia Server Docs",
|
||||
});
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import type { Theme } from "vitepress";
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
// https://vitepress.dev/guide/custom-theme
|
||||
import { h, type VNode } from "vue";
|
||||
import "./style.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: (): VNode => {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
// https://vitepress.dev/guide/extending-default-theme#layout-slots
|
||||
});
|
||||
},
|
||||
} satisfies Theme;
|
||||