Merge pull request #10 from lysand-org/main

v0.3.0
This commit is contained in:
Gaspard Wierzbinski 2024-03-11 20:52:57 -10:00 committed by GitHub
commit 8f8046186f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
172 changed files with 8793 additions and 3384 deletions

View file

@ -14,4 +14,6 @@ helm-charts
.idea
coverage*
uploads
logs
logs
dist
pages/dist

View file

@ -71,7 +71,7 @@ jobs:
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
uses: docker/build-push-action@v5.1.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

6
.gitignore vendored
View file

@ -168,4 +168,8 @@ dist
.yarn/install-state.gz
.pnp.\*
config/config.toml
uploads/
config/config.internal.toml
uploads/
pages/dist
log.txt
*.log

18
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"configurations": [
{
"type": "node",
"name": "vscode-jest-tests.v2.lysand",
"request": "launch",
"args": [
"test",
"${jest.testFile}"
],
"cwd": "/home/jessew/Dev/lysand",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"program": "/home/jessew/.bun/bin/bun"
}
]
}

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"jest.jestCommandLine": "/home/jessew/.bun/bin/bun test",
"jest.rootPath": "."
}

268
API.md Normal file
View file

@ -0,0 +1,268 @@
# API
The Lysand project uses the Mastodon API to interact with clients. However, the moderation API is custom-made for Lysand Server, as it allows for more fine-grained control over the server's behavior.
## Flags, ModTags and ModNotes
Flags are used by Lysand Server to automatically attribute tags to a status or account based on rules. ModTags and ModNotes are used by moderators to manually tag and take notes on statuses and accounts.
The difference between flags and modtags is that flags are automatically attributed by the server, while modtags are manually attributed by moderators.
### Flag Types
- `content_filter`: (Statuses only) The status contains content that was filtered by the server's content filter.
- `bio_filter`: (Accounts only) The account's bio contains content that was filtered by the server's content filter.
- `emoji_filter`: The status or account contains an emoji that was filtered by the server's content filter.
- `reported`: The status or account was previously reported by a user.
- `suspended`: The status or account was previously suspended by a moderator.
- `silenced`: The status or account was previously silenced by a moderator.
### ModTag Types
ModTag do not have set types and can be anything. Lysand Server autosuggest previously used tags when a moderator is adding a new tag to avoid duplicates.
### Data Format
```ts
type Flag = {
id: string,
// One of the following two fields will be present
flaggedStatus?: Status,
flaggedUser?: User,
flagType: string,
createdAt: string,
}
type ModTag = {
id: string,
// One of the following two fields will be present
taggedStatus?: Status,
taggedUser?: User,
mod: User,
tag: string,
createdAt: string,
}
type ModNote = {
id: string,
// One of the following two fields will be present
notedStatus?: Status,
notedUser?: User,
mod: User,
note: string,
createdAt: string,
}
```
The `User` and `Status` types are the same as the ones in the Mastodon API.
## Moderation API Routes
### `GET /api/v1/moderation/accounts/:id`
Returns full moderation data and flags for the account with the given ID.
Output format:
```ts
{
id: string, // Same ID as in account field
flags: Flag[],
modtags: ModTag[],
modnotes: ModNote[],
account: User,
}
```
### `GET /api/v1/moderation/statuses/:id`
Returns full moderation data and flags for the status with the given ID.
Output format:
```ts
{
id: string, // Same ID as in status field
flags: Flag[],
modtags: ModTag[],
modnotes: ModNote[],
status: Status,
}
```
### `POST /api/v1/moderation/accounts/:id/modtags`
Params:
- `tag`: string
Adds a modtag to the account with the given ID
### `POST /api/v1/moderation/statuses/:id/modtags`
Params:
- `tag`: string
Adds a modtag to the status with the given ID
### `POST /api/v1/moderation/accounts/:id/modnotes`
Params:
- `note`: string
Adds a modnote to the account with the given ID
### `POST /api/v1/moderation/statuses/:id/modnotes`
Params:
- `note`: string
Adds a modnote to the status with the given ID
### `DELETE /api/v1/moderation/accounts/:id/modtags/:modtag_id`
Deletes the modtag with the given ID from the account with the given ID
### `DELETE /api/v1/moderation/statuses/:id/modtags/:modtag_id`
Deletes the modtag with the given ID from the status with the given ID
### `DELETE /api/v1/moderation/accounts/:id/modnotes/:modnote_id`
Deletes the modnote with the given ID from the account with the given ID
### `DELETE /api/v1/moderation/statuses/:id/modnotes/:modnote_id`
Deletes the modnote with the given ID from the status with the given ID
### `GET /api/v1/moderation/modtags`
Returns a list of all modtags previously used by moderators
Output format:
```ts
{
tags: string[],
}
```
### `GET /api/v1/moderation/accounts/flags/search`
Allows moderators to search for accounts based on their flags, this can also include status flags
Params:
- `limit`: Number
- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results.
- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results.
- `flags`: String (optional). Comma-separated list of flag types to filter by. Can be left out to return accounts with at least one flag
- `flag_count`: Number (optional). Minimum number of flags to filter by
- `include_statuses`: Boolean (optional). If true, includes status flags in the search results
- `account_id`: Array of strings (optional). Filters accounts by account ID
This method returns a `Link` header the same way Mastodon does, to allow for pagination.
Output format:
```ts
{
accounts: {
account: User,
modnotes: ModNote[],
flags: Flag[],
statuses?: {
status: Status,
modnotes: ModNote[],
flags: Flag[],
}[],
}[],
}
```
### `GET /api/v1/moderation/statuses/flags/search`
Allows moderators to search for statuses based on their flags
Params:
- `limit`: Number
- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results.
- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results.
- `flags`: String (optional). Comma-separated list of flag types to filter by. Can be left out to return statuses with at least one flag
- `flag_count`: Number (optional). Minimum number of flags to filter by
- `account_id`: Array of strings (optional). Filters statuses by account ID
This method returns a `Link` header the same way Mastodon does, to allow for pagination.
Output format:
```ts
{
statuses: {
status: Status,
modnotes: ModNote[],
flags: Flag[],
}[],
}
```
### `GET /api/v1/moderation/accounts/modtags/search`
Allows moderators to search for accounts based on their modtags
Params:
- `limit`: Number
- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results.
- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results.
- `tags`: String (optional). Comma-separated list of tags to filter by. Can be left out to return accounts with at least one tag
- `tag_count`: Number (optional). Minimum number of tags to filter by
- `include_statuses`: Boolean (optional). If true, includes status tags in the search results
- `account_id`: Array of strings (optional). Filters accounts by account ID
This method returns a `Link` header the same way Mastodon does, to allow for pagination.
Output format:
```ts
{
accounts: {
account: User,
modnotes: ModNote[],
modtags: ModTag[],
statuses?: {
status: Status,
modnotes: ModNote[],
modtags: ModTag[],
}[],
}[],
}
```
### `GET /api/v1/moderation/statuses/modtags/search`
Allows moderators to search for statuses based on their modtags
Params:
- `limit`: Number
- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results.
- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results.
- `tags`: String (optional). Comma-separated list of tags to filter by. Can be left out to return statuses with at least one tag
- `tag_count`: Number (optional). Minimum number of tags to filter by
- `account_id`: Array of strings (optional). Filters statuses by account ID
- `include_statuses`: Boolean (optional). If true, includes status tags in the search results
This method returns a `Link` header the same way Mastodon does, to allow for pagination.
Output format:
```ts
{
statuses: {
status: Status,
modnotes: ModNote[],
modtags: ModTag[],
}[],
}
```

View file

@ -1,5 +1,6 @@
# Contributing to Lysand
> [!NOTE]
> This document was authored by [@CPlusPatch](https://github.com/CPlusPatch).
Thank you for your interest in contributing to Lysand! We welcome contributions from everyone, regardless of their level of experience or expertise.
@ -8,7 +9,7 @@ Thank you for your interest in contributing to Lysand! We welcome contributions
Lysand is built using the following technologies:
- [Bun](https://bun.sh) - A JavaScript runtime similar to Node.js, but improved
- [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
- [`pg_uuidv7`](https://github.com/fboulnois/pg_uuidv7) - A PostgreSQL extension that provides a UUIDv7 data type
- [UnoCSS](https://unocss.dev) - A utility-first CSS framework, used for the login page
@ -67,7 +68,10 @@ RUN chmod +x /docker-entrypoint-initdb.d/init.sh
```
4. Copy the `config.toml.example` file to `config.toml` and fill in the values (you can leave most things to the default, but you will need to configure things such as the database connection)
> [!WARNING]
> You should disable Prisma Redis caching while developing, as it can mess up tests
5. Generate the Prisma client:
```bash
@ -89,6 +93,15 @@ bun dev
If your port number is lower than 1024, you may need to run the command as root.
### Running the Vite server
To start the Vite server, run:
```sh
bun vite:dev
```
This should be run in a separate terminal window. The Vite server is used to serve the frontend assets and to provide hot module reloading.
## Running tests
To run the tests, run:
@ -96,7 +109,7 @@ To run the tests, run:
bun test
```
The tests are located in the `tests/` directory and follow a Jest-like syntax. The server must be started with `bun dev` before running the tests.
The tests are located in the `tests/` directory and follow a Jest-like syntax. The server does not need to be started before running the tests, as the tests will spawn their own Lysand server instance.
## Code style

View file

@ -1,31 +1,36 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1.0.14-alpine as base
FROM oven/bun:1.0.30-alpine as base
WORKDIR /usr/src/app
RUN apk add vips-dev
# Required for Prisma to work
COPY --from=node:18-alpine /usr/local/bin/node /usr/local/bin/node
# COPY --from=node:18-alpine /usr/local/bin/node /usr/local/bin/node
# install dependencies into temp directory
# this will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lockb /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production.
RUN mkdir -p /temp
COPY . /temp
WORKDIR /temp
RUN bun install --frozen-lockfile --production
# Build Vite in pages
RUN bunx --bun vite build pages
# Build the project
RUN bun run build.ts
WORKDIR /temp/dist
# copy production dependencies and source code into final image
FROM base AS release
# Create app directory
RUN mkdir -p /app
COPY --from=install /temp/prod/node_modules /app/node_modules
COPY . /app
COPY --from=install /temp/dist /app/dist
COPY entrypoint.sh /app
LABEL org.opencontainers.image.authors "Gaspard Wierzbinski (https://cpluspatch.dev)"
LABEL org.opencontainers.image.source "https://github.com/lysand-org/lysand"
@ -34,11 +39,8 @@ LABEL org.opencontainers.image.licenses "AGPL-3.0"
LABEL org.opencontainers.image.title "Lysand Server"
LABEL org.opencontainers.image.description "Lysand Server docker image"
# CD to app
WORKDIR /app
RUN bunx prisma generate
# CD to app
WORKDIR /app
ENV NODE_ENV=production
# Run migrations and start the server
ENTRYPOINT [ "bun", "migrate", "&&", "bun", "run", "index.ts" ]
ENTRYPOINT [ "./entrypoint.sh" "start" ]

185
README.md
View file

@ -4,14 +4,14 @@
![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) ![Bun](https://img.shields.io/badge/Bun-%23000000.svg?style=for-the-badge&logo=bun&logoColor=white) ![VS Code Insiders](https://img.shields.io/badge/VS%20Code%20Insiders-35b393.svg?style=for-the-badge&logo=visual-studio-code&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![ESLint](https://img.shields.io/badge/ESLint-4B3263?style=for-the-badge&logo=eslint&logoColor=white) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa?style=for-the-badge)](code_of_conduct.md)
## What is this?
This is a project to create a federated social network based on the [Lysand](https://lysand.org) protocol. It is currently in alpha phase, with basic federation and API support.
This is a project to create a federated social network based on the [Lysand](https://lysand.org) protocol. It is currently in alpha phase, with basic federation and almost complete Mastodon API support.
This project aims to be a fully featured social network, with a focus on privacy, security, and performance. It will implement the Mastodon API for support with clients that already support Mastodon or Pleroma.
This project aims to be a fully featured social network, with a focus on privacy, security, and performance. It implements the Mastodon API for support with clients that already support Mastodon or Pleroma.
> **Note:** This project is not affiliated with Mastodon or Pleroma, and is not a fork of either project. It is a new project built from the ground up.
> [!NOTE]
> This project is not affiliated with Mastodon or Pleroma, and is not a fork of either project. It is a new project built from the ground up.
## Features
@ -31,7 +31,8 @@ This project aims to be a fully featured social network, with a focus on privacy
## Benchmarks
> **Note**: These benchmarks are not representative of real-world performance, and are only meant to be used as a rough guide.
> [!NOTE]
> These benchmarks are not representative of real-world performance, and are only meant to be used as a rough guide. Load, and therefore performance, will vary depending on the server's hardware and software configuration, as well as user activity.
### Timeline Benchmarks
@ -63,17 +64,21 @@ $ bun run benchmarks/timelines.ts 10000
✓ 10000 requests fulfilled in 12.44852s
```
Lysand is extremely fast and can handle tens of thousands of HTTP requests per second on a good server.
Lysand is extremely fast and can handle thousands of HTTP requests per second on a good server.
## How do I run it?
### Requirements
- The [Bun Runtime](https://bun.sh), version 1.0.5 or later (usage of the latest version is recommended)
- The [Bun Runtime](https://bun.sh), version 1.0.30 or later (usage of the latest version is recommended)
- A PostgreSQL database
- (Optional but recommended) A Linux-based operating system
- (Optional if you want search) A working Meiliseach instance
> **Note**: We will not be offerring support to Windows or MacOS users. If you are using one of these operating systems, please use a virtual machine or container to run Lysand.
> [!WARNING]
> Lysand has not been tested on Windows or MacOS. It is recommended to use a Linux-based operating system to run Lysand.
>
> We will not be offerring support to Windows or MacOS users. If you are using one of these operating systems, please use a virtual machine or container to run Lysand.
### Installation
@ -125,6 +130,18 @@ RUN chmod +x /docker-entrypoint-initdb.d/init.sh
bun migrate
```
6. (If you want search)
Create a Meilisearch instance (using Docker is recommended). For a [`docker-compose`] file, copy the `meilisearch` service from the [`docker-compose.yml`](docker-compose.yml) file.
Set up Meiliseach's API key by passing the `MEILI_MASTER_KEY` environment variable to the server. Then, enale and configure search in the config file.
7. Build everything:
```bash
bun prod-build
```
You may now start the server with `bun start`. It lives in the `dist/` directory, all the other code can be removed from this point onwards.
In fact, the `bun start` script merely runs `bun run dist/index.js --prod`!
### Running
To run the server, simply run the following command:
@ -138,24 +155,31 @@ bun start
Lysand includes a built-in CLI for managing the server. To use it, simply run the following command:
```bash
bun cli
bun cli help
```
You can use the `help` command to see a list of available commands. These include creating users, deleting users and more.
If you are running a production build, you will need to run `bun run dist/cli.js` or `./entrypoint.sh cli` instead.
You can use the `help` command to see a list of available commands. These include creating users, deleting users and more. Each command also has a `--help,-h` flag that you can use to see more information about the command.
#### Scripting with the CLI
Some CLI commands that return data as tables can be used in scripts. To do so, you can use the `--json` flag to output the data as JSON instead of a table, or even `--csv` to output the data as CSV. See `bun cli help` for more information.
Some CLI commands that return data as tables can be used in scripts. To convert them to JSON or CSV, some commands allow you to specify a `--format` flag that can be either `"json"` or `"csv"`. See `bun cli help` or `bun cli <command> -h` for more information.
Flags can be used in any order and anywhere in the script (except for the `bun cli` command itself). The command arguments themselves must be in the correct order, however.
### Rebuilding the Search Index
You may use the `bun cli index rebuild` command to automatically push all posts and users to Meilisearch, if it is configured. This is useful if you have just set up Meilisearch, or if you accidentally deleted something.
### Using Database Commands
The `bun prisma` commands allows you to use Prisma commands without needing to add in environment variables for the database config. Just run Prisma commands as you would normally, replacing `bunx prisma` with `bun prisma`.
## With Docker
> **Note**: Docker is currently broken, as Bun with Prisma does not work well with Docker yet for unknown reasons. The following instructions are for when this is fixed.
> [!NOTE]
> Docker is currently broken, as Bun with Prisma does not work well with Docker yet for unknown reasons. The following instructions are for when this is fixed.
>
> These instructions will probably also work with Podman and other container runtimes.
@ -180,7 +204,7 @@ You may need root privileges to run Docker commands.
You can run CLI commands inside Docker using the following command:
```bash
sudo docker exec -it lysand bun cli ...
sudo docker exec -it lysand sh entrypoint.sh cli ...
```
### Running migrations inside Docker
@ -188,7 +212,7 @@ sudo docker exec -it lysand bun cli ...
You can run migrations inside Docker using the following command (if needed):
```bash
sudo docker exec -it lysand bun migrate
sudo docker exec -it lysand sh entrypoint.sh prisma migrate deploy
```
## Contributing
@ -202,7 +226,8 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil
## Federation
> **Warning**: Federation has not been tested outside of automated tests. It is not recommended to use this software in production.
> [!WARNING]
> Federation has not been tested outside of automated tests. It is not recommended to use this software in production.
The following extensions are currently supported or being worked on:
- `org.lysand:custom_emojis`: Custom emojis
@ -254,17 +279,21 @@ Working endpoints are:
- `/api/v1/blocks`
- `/api/v1/mutes`
- `/api/v2/media`
- `/api/v1/notifications`
Tests needed but completed:
- `/api/v1/media/:id`
- `/api/v2/media`
- `/api/v1/favourites`
- `/api/v1/accounts/:id/followers`
- `/api/v1/accounts/:id/following`
- `/api/v2/search`
Endpoints left:
- `/api/v1/reports`
- `/api/v1/accounts/:id/lists`
- `/api/v1/accounts/:id/following`
- `/api/v1/follow_requests`
- `/api/v1/follow_requests/:account_id/authorize`
- `/api/v1/follow_requests/:account_id/reject`
@ -308,11 +337,9 @@ Endpoints left:
- `/api/v1/lists/:id` (`GET`, `PUT`, `DELETE`)
- `/api/v1/markers` (`GET`, `POST`)
- `/api/v1/lists/:id/accounts` (`GET`, `POST`, `DELETE`)
- `/api/v1/notifications`
- `/api/v1/notifications/:id`
- `/api/v1/notifications/clear`
- `/api/v1/notifications/:id/dismiss`
- `/api/v2/search`
- `/api/v2/instance`
- `/api/v1/instance/peers`
- `/api/v1/instance/activity`
@ -330,126 +357,6 @@ Endpoints left:
WebSocket Streaming API also needed to be added (and push notifications)
## Configuration Values
Configuration can be found inside the `config.toml` file. The following values are available:
### Database
- `host`: The hostname or IP address of the database server. Example: `"localhost"`
- `port`: The port number to use for the database connection. Example: `48654`
- `username`: The username to use for the database connection. Example: `"lysand"`
- `password`: The password to use for the database connection. Example: `"mycoolpassword"`
- `database`: The name of the database to use. Example: `"lysand"`
### HTTP
- `base_url`: The base URL for the HTTP server. Example: `"https://lysand.social"`
- `bind`: The hostname or IP address to bind the HTTP server to. Example: `"http://localhost"`
- `bind_port`: The port number to bind the HTTP server to. Example: `"8080"`
#### Security
- `banned_ips`: An array of strings representing banned IPv4 or IPv6 IPs. Wildcards, networks and ranges are supported. Example: `[ "192.168.0.*" ]` (empty array)
### Media
- `backend`: Specifies the backend to use for media storage. Can be "local" or "s3", "local" uploads the file to the local filesystem.
- `deduplicate_media`: When set to true, the hash of media is checked when uploading to avoid duplication.
#### Conversion
- `convert_images`: Whether to convert uploaded images to another format. Example: `true`
- `convert_to`: The format to convert uploaded images to. Example: `"webp"`. Can be "jxl", "webp", "avif", "png", "jpg" or "gif".
### S3
- `endpoint`: The endpoint to use for the S3 server. Example: `"https://s3.example.com"`
- `access_key`: Access key to use for S3
- `secret_access_key`: Secret access key to use for S3
- `bucket_name`: The bucket to use for S3 (can be left empty)
- `region`: The region to use for S3 (can be left empty)
- `public_url`: The public URL to access uploaded media. Example: `"https://cdn.example.com"`
### SMTP
- `server`: The SMTP server to use for sending emails. Example: `"smtp.example.com"`
- `port`: The port number to use for the SMTP server. Example: `465`
- `username`: The username to use for the SMTP server. Example: `"test@example.com"`
- `password`: The password to use for the SMTP server. Example: `"password123"`
- `tls`: Whether to use TLS for the SMTP server. Example: `true`
### Email
- `send_on_report`: Whether to send an email to moderators when a report is received. Example: `false`
- `send_on_suspend`: Whether to send an email to moderators when a user is suspended. Example: `true`
- `send_on_unsuspend`: Whether to send an email to moderators when a user is unsuspended. Example: `false`
### Validation
- `max_displayname_size`: The maximum size of a user's display name, in characters. Example: `30`
- `max_bio_size`: The maximum size of a user's bio, in characters. Example: `160`
- `max_note_size`: The maximum size of a user's note, in characters. Example: `500`
- `max_avatar_size`: The maximum size of a user's avatar image, in bytes. Example: `1048576` (1 MB)
- `max_header_size`: The maximum size of a user's header image, in bytes. Example: `2097152` (2 MB)
- `max_media_size`: The maximum size of a media attachment, in bytes. Example: `5242880` (5 MB)
- `max_media_attachments`: The maximum number of media attachments allowed per post. Example: `4`
- `max_media_description_size`: The maximum size of a media attachment's description, in characters. Example: `100`
- `max_username_size`: The maximum size of a user's username, in characters. Example: `20`
- `username_blacklist`: An array of strings representing usernames that are not allowed to be used by users. Defaults are from Akkoma. Example: `["admin", "moderator"]`
- `blacklist_tempmail`: Whether to blacklist known temporary email providers. Example: `true`
- `email_blacklist`: Additional email providers to blacklist. Example: `["example.com", "test.com"]`
- `url_scheme_whitelist`: An array of strings representing valid URL schemes. URLs that do not use one of these schemes will be parsed as text. Example: `["http", "https"]`
- `allowed_mime_types`: An array of strings representing allowed MIME types for media attachments. Example: `["image/jpeg", "image/png", "video/mp4"]`
### Defaults
- `visibility`: The default visibility for new notes. Example: `"public"`
- `language`: The default language for new notes. Example: `"en"`
- `avatar`: The default avatar URL. Example: `""` (empty string)
- `header`: The default header URL. Example: `""` (empty string)
### ActivityPub
> **Note**: These options do nothing and date back to when Lysand had ActivityPub support. They will be removed in a future version.
- `use_tombstones`: Whether to use ActivityPub Tombstones instead of deleting objects. Example: `true`
- `fetch_all_collection_members`: Whether to fetch all members of collections (followers, following, etc) when receiving them. Example: `false`
- `reject_activities`: An array of instance domain names without "https" or glob patterns. Rejects all activities from these instances, simply doesn't save them at all. Example: `[ "mastodon.social" ]`
- `force_followers_only`: An array of instance domain names without "https" or glob patterns. Force posts from this instance to be followers only. Example: `[ "mastodon.social" ]`
- `discard_reports`: An array of instance domain names without "https" or glob patterns. Discard all reports from these instances. Example: `[ "mastodon.social" ]`
- `discard_deletes`: An array of instance domain names without "https" or glob patterns. Discard all deletes from these instances. Example: `[ "mastodon.social" ]`
- `discard_updates`: An array of instance domain names without "https" or glob patterns. Discard all updates (edits) from these instances. Example: `[]`
- `discard_banners`: An array of instance domain names without "https" or glob patterns. Discard all banners from these instances. Example: `[ "mastodon.social" ]`
- `discard_avatars`: An array of instance domain names without "https" or glob patterns. Discard all avatars from these instances. Example: `[ "mastodon.social" ]`
- `discard_follows`: An array of instance domain names without "https" or glob patterns. Discard all follow requests from these instances. Example: `[]`
- `force_sensitive`: An array of instance domain names without "https" or glob patterns. Force set these instances' media as sensitive. Example: `[ "mastodon.social" ]`
- `remove_media`: An array of instance domain names without "https" or glob patterns. Remove these instances' media. Example: `[ "mastodon.social" ]`
### Filters
- `note_filters`: An array of regex filters to drop notes from new activities. Example: `["(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+", "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+"]`
- `username_filters`: An array of regex filters to drop users from new activities based on their username. Example: `[ "^spammer-[a-z]" ]`
- `displayname_filters`: An array of regex filters to drop users from new activities based on their display name. Example: `[ "^spammer-[a-z]" ]`
- `bio_filters`: An array of regex filters to drop users from new activities based on their bio. Example: `[ "badword" ]`
- `emoji_filters`: An array of regex filters to drop users from new activities based on their emoji usage. Example: `[ ":bademoji:" ]`
### Logging
- `log_requests`: Whether to log all requests. Example: `true`
- `log_requests_verbose`: Whether to log request and their contents. Example: `false`
- `log_filters`: Whether to log all filtered objects. Example: `true`
### Ratelimits
- `duration_coeff`: The amount to multiply every route's duration by. Example: `1.0`
- `max_coeff`: The amount to multiply every route's max by. Example: `1.0`
### Custom Ratelimits
- `"/api/v1/timelines/public"`: An object representing a custom ratelimit for the specified API route. Example: `{ duration = 60, max = 200 }`
## License
This project is licensed under the [AGPL-3.0](LICENSE).
This project is licensed under the [AGPL-3.0](LICENSE).

View file

@ -2,10 +2,10 @@
* Usage: TOKEN=your_token_here bun benchmark:timeline <request_count>
*/
import { getConfig } from "@config";
import chalk from "chalk";
import { ConfigManager } from "config-manager";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
const token = process.env.TOKEN;
const requestCount = Number(process.argv[2]) || 100;

43
build.ts Normal file
View file

@ -0,0 +1,43 @@
// Delete dist directory
import { rm, cp, mkdir, exists } from "fs/promises";
if (!(await exists("./pages/dist"))) {
console.log("Please build the Vite server first, or use `bun prod-build`");
process.exit(1);
}
console.log(`Building at ${process.cwd()}`);
await rm("./dist", { recursive: true });
await mkdir(process.cwd() + "/dist");
//bun build --entrypoints ./index.ts ./prisma.ts ./cli.ts --outdir dist --target bun --splitting --minify --external bullmq,@prisma/client
await Bun.build({
entrypoints: [
process.cwd() + "/index.ts",
process.cwd() + "/prisma.ts",
process.cwd() + "/cli.ts",
],
outdir: process.cwd() + "/dist",
target: "bun",
splitting: true,
minify: true,
external: ["bullmq"],
}).then(output => {
if (!output.success) {
console.log(output.logs);
}
});
// Create pages directory
// mkdir ./dist/pages
await mkdir(process.cwd() + "/dist/pages");
// Copy Vite build output to dist
// cp -r ./pages/dist ./dist/pages
await cp(process.cwd() + "/pages/dist", process.cwd() + "/dist/pages/", {
recursive: true,
});
console.log(`Built!`);

BIN
bun.lockb

Binary file not shown.

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[install.scopes]
"@jsr" = "https://npm.jsr.io"

View file

@ -1,273 +0,0 @@
import type { GetObjectCommandOutput } from "@aws-sdk/client-s3";
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { ConfigType } from "@config";
import sharp from "sharp";
import { exists, mkdir } from "fs/promises";
class MediaBackend {
backend: string;
constructor(backend: string) {
this.backend = backend;
}
/**
* Adds media to the media backend
* @param media
* @returns The hash of the file in SHA-256 (hex format) with the file extension added to it
*/
async addMedia(media: File) {
const hash = new Bun.SHA256()
.update(await media.arrayBuffer())
.digest("hex");
return `${hash}.${media.name.split(".").pop()}`;
}
async convertMedia(media: File, config: ConfigType) {
const sharpCommand = sharp(await media.arrayBuffer());
// Rename ".jpg" files to ".jpeg" to avoid sharp errors
let name = media.name;
if (media.name.endsWith(".jpg")) {
name = media.name.replace(".jpg", ".jpeg");
}
const fileFormatToConvertTo = config.media.conversion.convert_to;
switch (fileFormatToConvertTo) {
case "png":
return new File(
[(await sharpCommand.png().toBuffer()).buffer],
// Replace the file extension with PNG
name.replace(/\.[^/.]+$/, ".png"),
{
type: "image/png",
}
);
case "webp":
return new File(
[(await sharpCommand.webp().toBuffer()).buffer],
// Replace the file extension with WebP
name.replace(/\.[^/.]+$/, ".webp"),
{
type: "image/webp",
}
);
case "jpeg":
return new File(
[(await sharpCommand.jpeg().toBuffer()).buffer],
// Replace the file extension with JPEG
name.replace(/\.[^/.]+$/, ".jpeg"),
{
type: "image/jpeg",
}
);
case "avif":
return new File(
[(await sharpCommand.avif().toBuffer()).buffer],
// Replace the file extension with AVIF
name.replace(/\.[^/.]+$/, ".avif"),
{
type: "image/avif",
}
);
// Needs special build of libvips
case "jxl":
return new File(
[(await sharpCommand.jxl().toBuffer()).buffer],
// Replace the file extension with JXL
name.replace(/\.[^/.]+$/, ".jxl"),
{
type: "image/jxl",
}
);
case "heif":
return new File(
[(await sharpCommand.heif().toBuffer()).buffer],
// Replace the file extension with HEIF
name.replace(/\.[^/.]+$/, ".heif"),
{
type: "image/heif",
}
);
default:
return media;
}
}
/**
* Retrieves element from media backend by hash
* @param hash The hash of the element in SHA-256 hex format
* @param extension The extension of the file
* @returns The file as a File object
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
async getMediaByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
hash: string
): Promise<File | null> {
return new File([], "test");
}
}
/**
* S3 Backend, stores files in S3
*/
export class S3Backend extends MediaBackend {
client: S3Client;
config: ConfigType;
constructor(config: ConfigType) {
super("s3");
this.config = config;
this.client = new S3Client({
endpoint: this.config.s3.endpoint,
region: this.config.s3.region || "auto",
credentials: {
accessKeyId: this.config.s3.access_key,
secretAccessKey: this.config.s3.secret_access_key,
},
});
}
async addMedia(media: File): Promise<string> {
if (this.config.media.conversion.convert_images) {
media = await this.convertMedia(media, this.config);
}
const hash = await super.addMedia(media);
if (!hash) {
throw new Error("Failed to hash file");
}
// Check if file is already present
const existingFile = await this.getMediaByHash(hash);
if (existingFile) {
// File already exists, so return the hash without uploading it
return hash;
}
const command = new PutObjectCommand({
Bucket: this.config.s3.bucket_name,
Key: hash,
Body: Buffer.from(await media.arrayBuffer()),
ContentType: media.type,
ContentLength: media.size,
Metadata: {
"x-amz-meta-original-name": media.name,
},
});
const response = await this.client.send(command);
if (response.$metadata.httpStatusCode !== 200) {
throw new Error("Failed to upload file");
}
return hash;
}
async getMediaByHash(hash: string): Promise<File | null> {
const command = new GetObjectCommand({
Bucket: this.config.s3.bucket_name,
Key: hash,
});
let response: GetObjectCommandOutput;
try {
response = await this.client.send(command);
} catch {
return null;
}
if (response.$metadata.httpStatusCode !== 200) {
throw new Error("Failed to get file");
}
const body = await response.Body?.transformToByteArray();
if (!body) {
throw new Error("Failed to get file");
}
return new File([body], hash, {
type: response.ContentType,
});
}
}
/**
* Local backend, stores files on filesystem
*/
export class LocalBackend extends MediaBackend {
config: ConfigType;
constructor(config: ConfigType) {
super("local");
this.config = config;
}
async addMedia(media: File): Promise<string> {
if (this.config.media.conversion.convert_images) {
media = await this.convertMedia(media, this.config);
}
const hash = await super.addMedia(media);
if (!(await exists(`${process.cwd()}/uploads`))) {
await mkdir(`${process.cwd()}/uploads`);
}
await Bun.write(Bun.file(`${process.cwd()}/uploads/${hash}`), media);
return hash;
}
async getMediaByHash(hash: string): Promise<File | null> {
const file = Bun.file(`${process.cwd()}/uploads/${hash}`);
if (!(await file.exists())) {
return null;
}
return new File([await file.arrayBuffer()], `${hash}`, {
type: file.type,
});
}
}
export const uploadFile = (file: File, config: ConfigType) => {
const backend = config.media.backend;
if (backend === "local") {
return new LocalBackend(config).addMedia(file);
} else if (backend === "s3") {
return new S3Backend(config).addMedia(file);
}
};
export const getFile = (
hash: string,
extension: string,
config: ConfigType
) => {
const backend = config.media.backend;
if (backend === "local") {
return new LocalBackend(config).getMediaByHash(hash);
} else if (backend === "s3") {
return new S3Backend(config).getMediaByHash(hash);
}
return null;
};

2148
cli.ts

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,36 @@ password = ""
database = 1
enabled = false
[meilisearch]
host = "localhost"
port = 40007
api_key = ""
enabled = true
[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",
]
# 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
[[oidc.providers]]
name = "CPlusPatch ID"
id = "cpluspatch-id"
url = "https://id.cpluspatch.com/application/o/lysand-testing/"
client_id = "XXXXXXXXXXXXXXXX"
client_secret = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
icon = "https://cpluspatch.com/images/icons/logo.svg"
[http]
base_url = "https://lysand.social"
bind = "http://localhost"
@ -41,6 +71,8 @@ tls = true
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
local_uploads_folder = "uploads"
[media.conversion]
convert_images = false
@ -240,6 +272,8 @@ emoji_filters = [] # NOT IMPLEMENTED
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
# Log all filtered objects
log_filters = true

View file

@ -1,8 +1,8 @@
import { Queue } from "bullmq";
import { getConfig } from "../utils/config";
import { PrismaClient } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
const client = new PrismaClient({
datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,

View file

@ -1,5 +1,6 @@
import type { ConfigType } from "@config";
import type { Attachment } from "@prisma/client";
import type { ConfigType } from "config-manager";
import { MediaBackendType } from "media-manager";
import type { APIAsyncAttachment } from "~types/entities/async_attachment";
import type { APIAttachment } from "~types/entities/attachment";
@ -56,11 +57,13 @@ export const attachmentToAPI = (
};
};
export const getUrl = (hash: string, config: ConfigType) => {
if (config.media.backend === "local") {
return `${config.http.base_url}/media/${hash}`;
} else if (config.media.backend === "s3") {
return `${config.s3.public_url}/${hash}`;
export const getUrl = (name: string, config: ConfigType) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${name}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${name}`;
}
return "";
};

View file

@ -76,3 +76,22 @@ export const emojiToLysand = (emoji: Emoji): LysandEmoji => {
alt: emoji.alt || undefined,
};
};
/**
* Converts the emoji to an ActivityPub object.
* @returns The ActivityPub object.
*/
export const emojiToActivityPub = (emoji: Emoji): any => {
// replace any with your ActivityPub Emoji type
return {
type: "Emoji",
name: `:${emoji.shortcode}:`,
updated: new Date().toISOString(),
icon: {
type: "Image",
url: emoji.url,
mediaType: emoji.content_type,
alt: emoji.alt || undefined,
},
};
};

View file

@ -1,10 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Like as LysandLike } from "~types/lysand/Object";
import { getConfig } from "@config";
import type { Like } from "@prisma/client";
import { client } from "~database/datasource";
import type { UserWithRelations } from "./User";
import type { StatusWithRelations } from "./Status";
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
/**
* Represents a Like entity in the database.
@ -16,7 +18,7 @@ export const toLysand = (like: Like): LysandLike => {
type: "Like",
created_at: new Date(like.createdAt).toISOString(),
object: (like as any).liked?.uri,
uri: `${getConfig().http.base_url}/actions/${like.id}`,
uri: `${config.http.base_url}/actions/${like.id}`,
};
};

View file

@ -13,7 +13,7 @@ export const notificationToAPI = async (
): Promise<APINotification> => {
return {
account: userToAPI(notification.account),
created_at: notification.createdAt.toISOString(),
created_at: new Date(notification.createdAt).toISOString(),
id: notification.id,
type: notification.type,
status: notification.status

View file

@ -1,4 +1,3 @@
import { getConfig } from "@config";
import { Worker } from "bullmq";
import { client, federationQueue } from "~database/datasource";
import {
@ -7,8 +6,9 @@ import {
type StatusWithRelations,
} from "./Status";
import type { User } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
export const federationWorker = new Worker(
"federation",
@ -44,7 +44,7 @@ export const federationWorker = new Worker(
instanceId: {
not: null,
},
}
}
: {},
// Mentioned users
{

View file

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { getConfig } from "@config";
import type { UserWithRelations } from "./User";
import {
fetchRemoteUser,
@ -24,8 +23,14 @@ import type { APIStatus } from "~types/entities/status";
import { applicationToAPI } from "./Application";
import { attachmentToAPI } from "./Attachment";
import type { APIAttachment } from "~types/entities/attachment";
import { sanitizeHtml } from "@sanitization";
import { parse } from "marked";
import linkifyStr from "linkify-string";
import linkifyHtml from "linkify-html";
import { addStausToMeilisearch } from "@meilisearch";
import { ConfigManager } from "config-manager";
const config = getConfig();
const config = await new ConfigManager({}).getConfig();
export const statusAndUserRelations: Prisma.StatusInclude = {
author: {
@ -206,7 +211,7 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
? {
status: replyStatus,
user: (replyStatus as any).author,
}
}
: undefined,
quote: quotingStatus || undefined,
});
@ -303,7 +308,7 @@ export const createNewStatus = async (data: {
visibility: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis: Emoji[];
emojis?: Emoji[];
content_type?: string;
uri?: string;
mentions?: User[];
@ -320,6 +325,11 @@ export const createNewStatus = async (data: {
let mentions = data.mentions || [];
// Parse emojis
const emojis = await parseEmojis(data.content);
data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis;
// Get list of mentioned users
if (mentions.length === 0) {
mentions = await client.user.findMany({
@ -335,11 +345,32 @@ export const createNewStatus = async (data: {
});
}
let formattedContent;
// Get HTML version of content
if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content))
);
} else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM
} else {
// Parse as plaintext
formattedContent = linkifyStr(data.content);
// Split by newline and add <p> tags
formattedContent = formattedContent
.split("\n")
.map(line => `<p>${line}</p>`)
.join("\n");
}
let status = await client.status.create({
data: {
authorId: data.account.id,
applicationId: data.application?.id,
content: data.content,
content: formattedContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
@ -358,7 +389,7 @@ export const createNewStatus = async (data: {
id: attachment,
};
}),
}
}
: undefined,
inReplyToPostId: data.reply?.status.id,
quotingPostId: data.quote?.id,
@ -390,7 +421,6 @@ export const createNewStatus = async (data: {
});
// Create notification
if (status.inReplyToPost) {
await client.notification.create({
data: {
@ -402,9 +432,113 @@ export const createNewStatus = async (data: {
});
}
// Add to search index
await addStausToMeilisearch(status);
return status;
};
export const editStatus = async (
status: StatusWithRelations,
data: {
content: string;
visibility?: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis?: Emoji[];
content_type?: string;
uri?: string;
mentions?: User[];
media_attachments?: string[];
}
) => {
// Get people mentioned in the content (match @username or @username@domain.com mentions
const mentionedPeople =
data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? [];
let mentions = data.mentions || [];
// Parse emojis
const emojis = await parseEmojis(data.content);
data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis;
// Get list of mentioned users
if (mentions.length === 0) {
mentions = await client.user.findMany({
where: {
OR: mentionedPeople.map(person => ({
username: person.split("@")[1],
instance: {
base_url: person.split("@")[2],
},
})),
},
include: userRelations,
});
}
let formattedContent;
// Get HTML version of content
if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content))
);
} else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM
} else {
// Parse as plaintext
formattedContent = linkifyStr(data.content);
// Split by newline and add <p> tags
formattedContent = formattedContent
.split("\n")
.map(line => `<p>${line}</p>`)
.join("\n");
}
const newStatus = await client.status.update({
where: {
id: status.id,
},
data: {
content: formattedContent,
contentSource: data.content,
contentType: data.content_type,
visibility: data.visibility,
sensitive: data.sensitive,
spoilerText: data.spoiler_text,
emojis: {
connect: data.emojis.map(emoji => {
return {
id: emoji.id,
};
}),
},
attachments: data.media_attachments
? {
connect: data.media_attachments.map(attachment => {
return {
id: attachment,
};
}),
}
: undefined,
mentions: {
connect: mentions.map(mention => {
return {
id: mention.id,
};
}),
},
},
include: statusAndUserRelations,
});
return newStatus;
};
export const isFavouritedBy = async (status: Status, user: User) => {
return !!(await client.like.findFirst({
where: {
@ -476,12 +610,59 @@ export const statusToAPI = async (
quote: status.quotingPost
? await statusToAPI(
status.quotingPost as unknown as StatusWithRelations
)
)
: null,
quote_id: status.quotingPost?.id || undefined,
};
};
/* export const statusToActivityPub = async (
status: StatusWithRelations
// user?: UserWithRelations
): Promise<any> => {
// replace any with your ActivityPub type
return {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://mastodon.social/schemas/litepub-0.1.jsonld",
],
id: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`,
type: "Note",
summary: status.spoilerText,
content: status.content,
published: new Date(status.createdAt).toISOString(),
url: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`,
attributedTo: `${config.http.base_url}/users/${status.authorId}`,
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [], // add recipients here
sensitive: status.sensitive,
attachment: (status.attachments ?? []).map(
a => attachmentToActivityPub(a) as ActivityPubAttachment // replace with your function
),
tag: [], // add tags here
replies: {
id: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}/replies`,
type: "Collection",
totalItems: status._count.replies,
},
likes: {
id: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}/likes`,
type: "Collection",
totalItems: status._count.likes,
},
shares: {
id: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}/shares`,
type: "Collection",
totalItems: status._count.reblogs,
},
inReplyTo: status.inReplyToPostId
? `${config.http.base_url}/users/${status.inReplyToPost?.authorId}/statuses/${status.inReplyToPostId}`
: null,
visibility: "public", // adjust as needed
// add more fields as needed
};
}; */
export const statusToLysand = (status: StatusWithRelations): Note => {
return {
type: "Note",

View file

@ -1,5 +1,3 @@
import type { ConfigType } from "@config";
import { getConfig } from "@config";
import type { APIAccount } from "~types/entities/account";
import type { User as LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
@ -9,6 +7,11 @@ import { client } from "~database/datasource";
import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance";
import type { APISource } from "~types/entities/source";
import { addUserToMeilisearch } from "@meilisearch";
import { ConfigManager, type ConfigType } from "config-manager";
const configManager = new ConfigManager({});
const config = await configManager.getConfig();
export interface AuthData {
user: UserWithRelations | null;
@ -151,6 +154,9 @@ export const fetchRemoteUser = async (uri: string) => {
},
});
// Add to Meilisearch
await addUserToMeilisearch(user);
const emojis = [];
for (const emoji of userEmojis) {
@ -197,7 +203,7 @@ export const createNewLocalUser = async (data: {
header?: string;
admin?: boolean;
}) => {
const config = getConfig();
const config = await configManager.getConfig();
const keys = await generateUserKeys();
@ -224,6 +230,9 @@ export const createNewLocalUser = async (data: {
},
});
// Add to Meilisearch
await addUserToMeilisearch(user);
return await client.user.update({
where: {
id: user.id,
@ -303,10 +312,10 @@ export const getRelationshipToOtherUser = async (
* Generates keys for the user.
*/
export const generateUserKeys = async () => {
const keys = (await crypto.subtle.generateKey("Ed25519", true, [
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
])) as CryptoKeyPair;
]);
const privateKey = btoa(
String.fromCharCode.apply(null, [
@ -337,8 +346,6 @@ export const userToAPI = (
user: UserWithRelations,
isOwnAccount = false
): APIAccount => {
const config = getConfig();
return {
id: user.id,
username: user.username,
@ -366,7 +373,7 @@ export const userToAPI = (
header_static: "",
acct:
user.instance === null
? `${user.username}`
? user.username
: `${user.username}@${user.instance.base_url}`,
// TODO: Add these fields
limited: false,
@ -417,13 +424,13 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
username: user.username,
avatar: [
{
content: getAvatarUrl(user, getConfig()) || "",
content: getAvatarUrl(user, config) || "",
content_type: `image/${user.avatar.split(".")[1]}`,
},
],
header: [
{
content: getHeaderUrl(user, getConfig()) || "",
content: getHeaderUrl(user, config) || "",
content_type: `image/${user.header.split(".")[1]}`,
},
],
@ -451,7 +458,7 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
],
})),
public_key: {
actor: `${getConfig().http.base_url}/users/${user.id}`,
actor: `${config.http.base_url}/users/${user.id}`,
public_key: user.publicKey,
},
extensions: {

View file

@ -35,6 +35,18 @@ services:
restart: unless-stopped
networks:
- lysand-net
meilisearch:
stdin_open: true
environment:
- MEILI_MASTER_KEY=add_your_key_here
tty: true
networks:
- lysand-net
volumes:
- ./meili-data:/meili_data
image: getmeili/meilisearch:v1.5
container_name: lysand-meilisearch
restart: unless-stopped
networks:
lysand-net:

37
entrypoint.sh Executable file
View file

@ -0,0 +1,37 @@
#!/bin/sh
# This script is a wrapper for the main server, CLI and Prisma binaries.
# Commands:
# - `start`: Starts the server
# - `cli`: Starts the CLI, sends all arguments to it
# - `prisma`: Execute a Prisma command, sends
# Exit immediately if a command exits with a non-zero status.
set -e
# Parse first argument
case "$1" in
"start")
# Start the server
exec bun run ./dist/index.js --prod
;;
"cli")
# Start the CLI
shift 1
exec bun run ./dist/cli.js "$@"
;;
"prisma")
# Proxy all Prisma commands
# Use output of dist/prisma.js to get the env variable
shift 1
# Set DATABASE_URL env variable to the output of bun run ./dist/prisma.js
export DATABASE_URL=$(bun run ./dist/prisma.js)
# Execute the Prisma binary
exec bunx prisma "$@"
;;
*)
# Run custom commands
exec "$@"
;;
esac
```

206
index.ts
View file

@ -1,41 +1,38 @@
import { getConfig } from "@config";
import { jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import chalk from "chalk";
import { appendFile } from "fs/promises";
import { matches } from "ip-matching";
import type { AuthData } from "~database/entities/User";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
import { mkdir } from "fs/promises";
import { client } from "~database/datasource";
import type { PrismaClientInitializationError } from "@prisma/client/runtime/library";
import { HookTypes, Server } from "~plugins/types";
import { initializeRedisCache } from "@redis";
import { connectMeili } from "@meilisearch";
import { ConfigManager } from "config-manager";
import { client } from "~database/datasource";
import { LogLevel, LogManager, MultiLogManager } from "log-manager";
import { moduleIsEntry } from "@module";
import { createServer } from "~server";
const timeAtStart = performance.now();
const server = new Server();
const router = new Bun.FileSystemRouter({
style: "nextjs",
dir: process.cwd() + "/server/api",
});
const configManager = new ConfigManager({});
const config = await configManager.getConfig();
console.log(`${chalk.green(`>`)} ${chalk.bold("Starting Lysand...")}`);
server.emit(HookTypes.PreServe);
const config = getConfig();
const requests_log = Bun.file(process.cwd() + "/logs/requests.log");
const isEntry = moduleIsEntry(import.meta.url);
// If imported as a module, redirect logs to /dev/null to not pollute console (e.g. in tests)
const logger = new LogManager(isEntry ? requests_log : Bun.file(`/dev/null`));
const consoleLogger = new LogManager(
isEntry ? Bun.stdout : Bun.file(`/dev/null`)
);
const dualLogger = new MultiLogManager([logger, consoleLogger]);
if (!(await requests_log.exists())) {
console.log(`${chalk.green(``)} ${chalk.bold("Creating logs folder...")}`);
await mkdir(process.cwd() + "/logs");
await Bun.write(process.cwd() + "/logs/requests.log", "");
}
await dualLogger.log(LogLevel.INFO, "Lysand", "Starting Lysand...");
// NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead
const isProd =
process.env.NODE_ENV === "production" || process.argv.includes("--prod");
const redisCache = await initializeRedisCache();
if (config.meilisearch.enabled) {
await connectMeili(dualLogger);
}
if (redisCache) {
client.$use(redisCache);
}
@ -46,154 +43,23 @@ try {
postCount = await client.status.count();
} catch (e) {
const error = e as PrismaClientInitializationError;
console.error(
`${chalk.red(``)} ${chalk.bold(
"Error while connecting to database: "
)} ${error.message}`
);
await logger.logError(LogLevel.CRITICAL, "Database", error);
await consoleLogger.logError(LogLevel.CRITICAL, "Database", error);
process.exit(1);
}
Bun.serve({
port: config.http.bind_port,
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
async fetch(req) {
/* Check for banned IPs */
const request_ip = this.requestIP(req)?.address ?? "";
const server = createServer(config, configManager, dualLogger, isProd);
for (const ip of config.http.banned_ips) {
try {
if (matches(ip, request_ip)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
}
} catch (e) {
console.error(`[-] Error while parsing banned IP "${ip}" `);
throw e;
}
}
await logRequest(req);
if (req.method === "OPTIONS") {
return jsonResponse({});
}
const matchedRoute = router.match(req);
if (matchedRoute) {
const file: {
meta: APIRouteMeta;
default: (
req: Request,
matchedRoute: MatchedRoute,
auth: AuthData
) => Response | Promise<Response>;
} = await import(matchedRoute.filePath);
const meta = file.meta;
// Check for allowed requests
if (!meta.allowedMethods.includes(req.method as any)) {
return new Response(undefined, {
status: 405,
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
", "
)}`,
});
}
// TODO: Check for ratelimits
const auth = await getFromRequest(req);
// Check for authentication if required
if (meta.auth.required) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
} else if (
(meta.auth.requiredOnMethods ?? []).includes(req.method as any)
) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
}
return await file.default(req.clone(), matchedRoute, auth);
} else {
return new Response(undefined, {
status: 404,
statusText: "Route not found",
});
}
},
});
const logRequest = async (req: Request) => {
if (config.logging.log_requests_verbose) {
await appendFile(
`${process.cwd()}/logs/requests.log`,
`[${new Date().toISOString()}] ${req.method} ${
req.url
}\n\tHeaders:\n`
);
// Add headers
const headers = req.headers.entries();
for (const [key, value] of headers) {
await appendFile(
`${process.cwd()}/logs/requests.log`,
`\t\t${key}: ${value}\n`
);
}
const body = await req.clone().text();
await appendFile(
`${process.cwd()}/logs/requests.log`,
`\tBody:\n\t${body}\n`
);
} else if (config.logging.log_requests) {
await appendFile(
process.cwd() + "/logs/requests.log",
`[${new Date().toISOString()}] ${req.method} ${req.url}\n`
);
}
};
// Remove previous console.log
// console.clear();
console.log(
`${chalk.green(``)} ${chalk.bold(
`Lysand started at ${chalk.blue(
`${config.http.bind}:${config.http.bind_port}`
)} in ${chalk.gray((performance.now() - timeAtStart).toFixed(0))}ms`
)}`
await dualLogger.log(
LogLevel.INFO,
"Server",
`Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`
);
console.log(
`${chalk.green(``)} ${chalk.bold(`Database is ${chalk.blue("online")}`)}`
await dualLogger.log(
LogLevel.INFO,
"Database",
`Database is online, now serving ${postCount} posts`
);
// Print "serving x posts"
console.log(
`${chalk.green(``)} ${chalk.bold(
`Serving ${chalk.blue(postCount)} posts`
)}`
);
server.emit(HookTypes.PostServe, {
postCount,
});
export { config, server };

View file

@ -2,7 +2,7 @@
"name": "lysand",
"module": "index.ts",
"type": "module",
"version": "0.1.2",
"version": "0.3.0",
"description": "A project to build a federated social network",
"author": {
"email": "contact@cpluspatch.com",
@ -32,14 +32,18 @@
},
"private": true,
"scripts": {
"dev": "bun run index.ts",
"start": "bun run index.ts",
"dev": "bun run --watch index.ts",
"vite:dev": "bunx --bun vite pages",
"vite:build": "bunx --bun vite build pages",
"start": "NODE_ENV=production bun run dist/index.js --prod",
"migrate-dev": "bun prisma migrate dev",
"migrate": "bun prisma migrate deploy",
"lint": "eslint --config .eslintrc.cjs --ext .ts .",
"prisma": "bun run prisma.ts",
"lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .",
"prod-build": "bunx --bun vite build pages && bun run build.ts",
"prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma",
"generate": "bun prisma generate",
"benchmark:timeline": "bun run benchmarks/timelines.ts",
"cloc": "cloc . --exclude-dir node_modules,dist",
"cli": "bun run cli.ts"
},
"trustedDependencies": [
@ -53,9 +57,9 @@
"@types/html-to-text": "^9.0.4",
"@types/ioredis": "^5.0.0",
"@types/jsonld": "^1.5.13",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"@unocss/cli": "^0.57.7",
"@typescript-eslint/eslint-plugin": "latest",
"@typescript-eslint/parser": "latest",
"@unocss/cli": "latest",
"activitypub-types": "^1.0.3",
"bun-types": "latest",
"eslint": "^8.54.0",
@ -64,29 +68,52 @@
"eslint-formatter-summary": "^1.1.0",
"eslint-plugin-prettier": "^5.0.1",
"prettier": "^3.1.0",
"typescript": "^5.3.2",
"unocss": "^0.57.7"
"typescript": "latest",
"unocss": "latest",
"@vitejs/plugin-vue": "latest",
"@vueuse/head": "^2.0.0",
"vite": "latest",
"vite-ssr": "^0.17.1",
"vue": "^3.3.9",
"vue-router": "^4.2.5",
"vue-tsc": "latest"
},
"peerDependencies": {
"typescript": "^5.3.2"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.461.0",
"@iarna/toml": "^2.2.5",
"@json2csv/plainjs": "^7.0.6",
"@prisma/client": "^5.6.0",
"blurhash": "^2.0.5",
"bullmq": "^4.14.4",
"bullmq": "latest",
"chalk": "^5.3.0",
"cli-parser": "file:packages/cli-parser",
"cli-table": "^0.3.11",
"config-manager": "file:packages/config-manager",
"eventemitter3": "^5.0.1",
"extract-zip": "^2.0.1",
"html-to-text": "^9.0.5",
"ioredis": "^5.3.2",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "^1.10.0",
"isomorphic-dompurify": "latest",
"jsonld": "^8.3.1",
"marked": "^9.1.2",
"linkify-html": "^4.1.3",
"linkify-string": "^4.1.3",
"linkifyjs": "^4.1.3",
"log-manager": "file:packages/log-manager",
"marked": "latest",
"media-manager": "file:packages/media-manager",
"megalodon": "^9.1.1",
"meilisearch": "latest",
"merge-deep-ts": "^1.2.6",
"next-route-matcher": "^1.0.1",
"oauth4webapi": "^2.4.0",
"prisma": "^5.6.0",
"prisma-redis-middleware": "^4.8.0",
"request-parser": "file:packages/request-parser",
"semver": "^7.5.4",
"sharp": "^0.33.0-rc.2"
}

BIN
packages/cli-parser/bun.lockb Executable file

Binary file not shown.

View file

@ -0,0 +1,23 @@
export interface CliParameter {
name: string;
/* Like -v for --version */
shortName?: string;
/**
* If not positioned, the argument will need to be called with --name value instead of just value
* @default true
*/
positioned?: boolean;
/* Whether the argument needs a value (requires positioned to be false) */
needsValue?: boolean;
optional?: true;
type: CliParameterType;
description?: string;
}
export enum CliParameterType {
STRING = "string",
NUMBER = "number",
BOOLEAN = "boolean",
ARRAY = "array",
EMPTY = "empty",
}

View file

@ -0,0 +1,420 @@
import { CliParameterType, type CliParameter } from "./cli-builder.type";
import chalk from "chalk";
import strip from "strip-ansi";
export function startsWithArray(fullArray: any[], startArray: any[]) {
if (startArray.length > fullArray.length) {
return false;
}
return fullArray
.slice(0, startArray.length)
.every((value, index) => value === startArray[index]);
}
interface TreeType {
[key: string]: CliCommand | TreeType;
}
/**
* Builder for a CLI
* @param commands Array of commands to register
*/
export class CliBuilder {
constructor(public commands: CliCommand[] = []) {}
/**
* Add command to the CLI
* @throws Error if command already exists
* @param command Command to add
*/
registerCommand(command: CliCommand) {
if (this.checkIfCommandAlreadyExists(command)) {
throw new Error(
`Command category '${command.categories.join(" ")}' already exists`
);
}
this.commands.push(command);
}
/**
* Add multiple commands to the CLI
* @throws Error if command already exists
* @param commands Commands to add
*/
registerCommands(commands: CliCommand[]) {
const existingCommand = commands.find(command =>
this.checkIfCommandAlreadyExists(command)
);
if (existingCommand) {
throw new Error(
`Command category '${existingCommand.categories.join(" ")}' already exists`
);
}
this.commands.push(...commands);
}
/**
* Remove command from the CLI
* @param command Command to remove
*/
deregisterCommand(command: CliCommand) {
this.commands = this.commands.filter(
registeredCommand => registeredCommand !== command
);
}
/**
* Remove multiple commands from the CLI
* @param commands Commands to remove
*/
deregisterCommands(commands: CliCommand[]) {
this.commands = this.commands.filter(
registeredCommand => !commands.includes(registeredCommand)
);
}
checkIfCommandAlreadyExists(command: CliCommand) {
return this.commands.some(
registeredCommand =>
registeredCommand.categories.length ==
command.categories.length &&
registeredCommand.categories.every(
(category, index) => category === command.categories[index]
)
);
}
/**
* Get relevant args for the command (without executable or runtime)
* @param args Arguments passed to the CLI
*/
private getRelevantArgs(args: string[]) {
if (args[0].startsWith("./")) {
// Formatted like ./cli.ts [command]
return args.slice(1);
} else if (args[0].includes("bun")) {
// Formatted like bun cli.ts [command]
return args.slice(2);
} else {
return args;
}
}
/**
* Turn raw system args into a CLI command and run it
* @param args Args directly from process.argv
*/
async processArgs(args: string[]) {
const revelantArgs = this.getRelevantArgs(args);
// Handle "-h", "--help" and "help" commands as special cases
if (revelantArgs.length === 1) {
if (["-h", "--help", "help"].includes(revelantArgs[0])) {
this.displayHelp();
return;
}
}
// Find revelant command
// Search for a command with as many categories matching args as possible
const matchingCommands = this.commands.filter(command =>
startsWithArray(revelantArgs, command.categories)
);
if (matchingCommands.length === 0) {
console.log(
`Invalid command "${revelantArgs.join(" ")}". Please use the ${chalk.bold("help")} command to see a list of commands`
);
return 0;
}
// Get command with largest category size
const command = matchingCommands.reduce((prev, current) =>
prev.categories.length > current.categories.length ? prev : current
);
const argsWithoutCategories = revelantArgs.slice(
command.categories.length
);
return await command.run(argsWithoutCategories);
}
/**
* Recursively urns the commands into a tree where subcategories mark each sub-branch
* @example
* ```txt
* user verify
* user delete
* user new admin
* user new
* ->
* user
* verify
* delete
* new
* admin
* ""
* ```
*/
getCommandTree(commands: CliCommand[]): TreeType {
const tree: TreeType = {};
for (const command of commands) {
let currentLevel = tree; // Start at the root
// Split the command into parts and iterate over them
for (const part of command.categories) {
// If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!currentLevel[part] && part !== "__proto__") {
// If this is the last part of the command, add the command itself
if (
part ===
command.categories[command.categories.length - 1]
) {
currentLevel[part] = command;
break;
}
currentLevel[part] = {};
}
// Move down to the next level of the tree
currentLevel = currentLevel[part] as TreeType;
}
}
return tree;
}
/**
* Display help for every command in a tree manner
*/
displayHelp() {
/*
user
set
admin: List of admin commands
--prod: Whether to run in production
--dev: Whether to run in development
username: Username of the admin
Example: user set admin --prod --dev --username John
delete
...
verify
...
*/
const tree = this.getCommandTree(this.commands);
let writeBuffer = "";
const displayTree = (tree: TreeType, depth = 0) => {
for (const [key, value] of Object.entries(tree)) {
if (value instanceof CliCommand) {
writeBuffer += `${" ".repeat(depth)}${chalk.blue(key)}|${chalk.underline(value.description)}\n`;
const positionedArgs = value.argTypes.filter(
arg => arg.positioned ?? true
);
const unpositionedArgs = value.argTypes.filter(
arg => !(arg.positioned ?? true)
);
for (const arg of positionedArgs) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.green(
arg.name
)}|${
arg.description ?? "(no description)"
} ${arg.optional ? chalk.gray("(optional)") : ""}\n`;
}
for (const arg of unpositionedArgs) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.yellow("--" + arg.name)}${arg.shortName ? ", " + chalk.yellow("-" + arg.shortName) : ""}|${
arg.description ?? "(no description)"
} ${arg.optional ? chalk.gray("(optional)") : ""}\n`;
}
if (value.example) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold("Example:")} ${chalk.bgGray(
value.example
)}\n`;
}
} else {
writeBuffer += `${" ".repeat(depth)}${chalk.blue(key)}\n`;
displayTree(value, depth + 1);
}
}
};
displayTree(tree);
// Replace all "|" with enough dots so that the text on the left + the dots = the same length
const optimal_length = Number(
// @ts-expect-error Slightly hacky but works
writeBuffer.split("\n").reduce((prev, current) => {
// If previousValue is empty
if (!prev)
return current.includes("|")
? current.split("|")[0].length
: 0;
if (!current.includes("|")) return prev;
const [left] = current.split("|");
// Strip ANSI color codes or they mess up the length
return Math.max(Number(prev), strip(left).length);
})
);
for (const line of writeBuffer.split("\n")) {
const [left, right] = line.split("|");
if (!right) {
console.log(left);
continue;
}
// Strip ANSI color codes or they mess up the length
const dots = ".".repeat(optimal_length + 5 - strip(left).length);
console.log(`${left}${dots}${right}`);
}
}
}
type ExecuteFunction<T> = (
instance: CliCommand,
args: Partial<T>
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => Promise<number> | Promise<void> | number | void;
/**
* A command that can be executed from the command line
* @param categories Example: `["user", "create"]` for the command `./cli user create --name John`
*/
export class CliCommand<T = any> {
constructor(
public categories: string[],
public argTypes: CliParameter[],
private execute: ExecuteFunction<T>,
public description?: string,
public example?: string
) {}
/**
* Display help message for the command
* formatted with Chalk and with emojis
*/
displayHelp() {
const positionedArgs = this.argTypes.filter(
arg => arg.positioned ?? true
);
const unpositionedArgs = this.argTypes.filter(
arg => !(arg.positioned ?? true)
);
const helpMessage = `
${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))}
${this.description ? `${chalk.cyan(this.description)}\n` : ""}
${chalk.magenta("🔧 Arguments:")}
${positionedArgs
.map(
arg =>
`${chalk.bold(arg.name)}: ${chalk.blue(arg.description ?? "(no description)")} ${
arg.optional ? chalk.gray("(optional)") : ""
}`
)
.join("\n")}
${unpositionedArgs
.map(
arg =>
`--${chalk.bold(arg.name)}${arg.shortName ? `, -${arg.shortName}` : ""}: ${chalk.blue(arg.description ?? "(no description)")} ${
arg.optional ? chalk.gray("(optional)") : ""
}`
)
.join(
"\n"
)}${this.example ? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}` : ""}
`;
console.log(helpMessage);
}
/**
* Parses string array arguments into a full JavaScript object
* @param argsWithoutCategories
* @returns
*/
private parseArgs(argsWithoutCategories: string[]): Record<string, any> {
const parsedArgs: Record<string, any> = {};
let currentParameter: CliParameter | null = null;
for (let i = 0; i < argsWithoutCategories.length; i++) {
const arg = argsWithoutCategories[i];
if (arg.startsWith("--")) {
const argName = arg.substring(2);
currentParameter =
this.argTypes.find(argType => argType.name === argName) ||
null;
if (currentParameter && !currentParameter.needsValue) {
parsedArgs[argName] = true;
currentParameter = null;
} else if (currentParameter && currentParameter.needsValue) {
parsedArgs[argName] = this.castArgValue(
argsWithoutCategories[i + 1],
currentParameter.type
);
i++;
currentParameter = null;
}
} else if (arg.startsWith("-")) {
const shortName = arg.substring(1);
const argType = this.argTypes.find(
argType => argType.shortName === shortName
);
if (argType && !argType.needsValue) {
parsedArgs[argType.name] = true;
} else if (argType && argType.needsValue) {
parsedArgs[argType.name] = this.castArgValue(
argsWithoutCategories[i + 1],
argType.type
);
i++;
}
} else if (currentParameter) {
parsedArgs[currentParameter.name] = this.castArgValue(
arg,
currentParameter.type
);
currentParameter = null;
} else {
const positionedArgType = this.argTypes.find(
argType => argType.positioned && !parsedArgs[argType.name]
);
if (positionedArgType) {
parsedArgs[positionedArgType.name] = this.castArgValue(
arg,
positionedArgType.type
);
}
}
}
return parsedArgs;
}
private castArgValue(value: string, type: CliParameter["type"]): any {
switch (type) {
case CliParameterType.STRING:
return value;
case CliParameterType.NUMBER:
return Number(value);
case CliParameterType.BOOLEAN:
return value === "true";
case CliParameterType.ARRAY:
return value.split(",");
default:
return value;
}
}
/**
* Runs the execute function with the parsed parameters as an argument
*/
async run(argsWithoutCategories: string[]) {
const args = this.parseArgs(argsWithoutCategories);
return await this.execute(this, args as any);
}
}

View file

@ -0,0 +1,6 @@
{
"name": "arg-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "strip-ansi": "^7.1.0" }
}

View file

@ -0,0 +1,485 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliCommand, CliBuilder, startsWithArray } from "..";
import { describe, beforeEach, it, expect, jest, spyOn } from "bun:test";
import stripAnsi from "strip-ansi";
import { CliParameterType } from "../cli-builder.type";
describe("startsWithArray", () => {
it("should return true when fullArray starts with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["a", "b", "c"];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return false when fullArray does not start with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["b", "c", "d"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
it("should return true when startArray is empty", () => {
const fullArray = ["a", "b", "c", "d", "e"];
const startArray: any[] = [];
expect(startsWithArray(fullArray, startArray)).toBe(true);
});
it("should return false when fullArray is shorter than startArray", () => {
const fullArray = ["a", "b", "c"];
const startArray = ["a", "b", "c", "d", "e"];
expect(startsWithArray(fullArray, startArray)).toBe(false);
});
});
describe("CliCommand", () => {
let cliCommand: CliCommand;
beforeEach(() => {
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
shortName: "a",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
}
);
});
it("should parse string arguments correctly", () => {
const args = cliCommand["parseArgs"]([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should parse short names for arguments too", () => {
const args = cliCommand["parseArgs"]([
"--arg1",
"value1",
"-a",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(args).toEqual({
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should cast argument values correctly", () => {
expect(cliCommand["castArgValue"]("42", CliParameterType.NUMBER)).toBe(
42
);
expect(
cliCommand["castArgValue"]("true", CliParameterType.BOOLEAN)
).toBe(true);
expect(
cliCommand["castArgValue"]("value1,value2", CliParameterType.ARRAY)
).toEqual(["value1", "value2"]);
});
it("should run the execute function with the parsed parameters", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
mockExecute
);
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
});
});
it("should work with a mix of positioned and non-positioned arguments", async () => {
const mockExecute = jest.fn();
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
{
name: "arg5",
type: CliParameterType.STRING,
needsValue: true,
positioned: true,
},
],
mockExecute
);
await cliCommand.run([
"--arg1",
"value1",
"--arg2",
"42",
"--arg3",
"--arg4",
"value1,value2",
"value5",
]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1",
arg2: 42,
arg3: true,
arg4: ["value1", "value2"],
arg5: "value5",
});
});
it("should display help message correctly", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
description: "Argument 1",
optional: true,
},
{
name: "arg2",
type: CliParameterType.NUMBER,
needsValue: true,
description: "Argument 2",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
description: "Argument 3",
optional: true,
positioned: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
description: "Argument 4",
positioned: false,
},
],
() => {
// Do nothing
},
"This is a test command",
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2"
);
cliCommand.displayHelp();
const loggedString = consoleLogSpy.mock.calls.map(call =>
stripAnsi(call[0])
)[0];
consoleLogSpy.mockRestore();
expect(loggedString).toContain("📚 Command: category1 category2");
expect(loggedString).toContain("🔧 Arguments:");
expect(loggedString).toContain("arg1: Argument 1 (optional)");
expect(loggedString).toContain("arg2: Argument 2");
expect(loggedString).toContain("--arg3: Argument 3 (optional)");
expect(loggedString).toContain("--arg4: Argument 4");
expect(loggedString).toContain("🚀 Example:");
expect(loggedString).toContain(
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2"
);
});
});
describe("CliBuilder", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
beforeEach(() => {
mockCommand1 = new CliCommand(["category1"], [], jest.fn());
mockCommand2 = new CliCommand(["category2"], [], jest.fn());
cliBuilder = new CliBuilder([mockCommand1]);
});
it("should register a command correctly", () => {
cliBuilder.registerCommand(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand2);
});
it("should register multiple commands correctly", () => {
const mockCommand3 = new CliCommand(["category3"], [], jest.fn());
cliBuilder.registerCommands([mockCommand2, mockCommand3]);
expect(cliBuilder.commands).toContain(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand3);
});
it("should error when adding duplicates", () => {
expect(() => {
cliBuilder.registerCommand(mockCommand1);
}).toThrow();
expect(() => {
cliBuilder.registerCommands([mockCommand1]);
}).toThrow();
});
it("should deregister a command correctly", () => {
cliBuilder.deregisterCommand(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand1);
});
it("should deregister multiple commands correctly", () => {
cliBuilder.registerCommand(mockCommand2);
cliBuilder.deregisterCommands([mockCommand1, mockCommand2]);
expect(cliBuilder.commands).not.toContain(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand2);
});
it("should process args correctly", async () => {
const mockExecute = jest.fn();
const mockCommand = new CliCommand(
["category1", "sub1"],
[
{
name: "arg1",
type: CliParameterType.STRING,
needsValue: true,
positioned: false,
},
],
mockExecute
);
cliBuilder.registerCommand(mockCommand);
await cliBuilder.processArgs([
"./cli.ts",
"category1",
"sub1",
"--arg1",
"value1",
]);
expect(mockExecute).toHaveBeenCalledWith(expect.anything(), {
arg1: "value1",
});
});
describe("should build command tree", () => {
let cliBuilder: CliBuilder;
let mockCommand1: CliCommand;
let mockCommand2: CliCommand;
let mockCommand3: CliCommand;
let mockCommand4: CliCommand;
let mockCommand5: CliCommand;
beforeEach(() => {
mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn());
mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn());
mockCommand3 = new CliCommand(
["user", "new", "admin"],
[],
jest.fn()
);
mockCommand4 = new CliCommand(["user", "new"], [], jest.fn());
mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn());
cliBuilder = new CliBuilder([
mockCommand1,
mockCommand2,
mockCommand3,
mockCommand4,
mockCommand5,
]);
});
it("should build the command tree correctly", () => {
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
delete: mockCommand2,
new: {
admin: mockCommand3,
},
},
admin: {
delete: mockCommand5,
},
});
});
it("should build the command tree correctly when there are no commands", () => {
cliBuilder = new CliBuilder([]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({});
});
it("should build the command tree correctly when there is only one command", () => {
cliBuilder = new CliBuilder([mockCommand1]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({
user: {
verify: mockCommand1,
},
});
});
});
it("should show help menu", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing
});
const cliBuilder = new CliBuilder();
const cliCommand = new CliCommand(
["category1", "category2"],
[
{
name: "name",
type: CliParameterType.STRING,
needsValue: true,
description: "Name of new item",
},
{
name: "delete-previous",
type: CliParameterType.NUMBER,
needsValue: false,
positioned: false,
optional: true,
description: "Also delete the previous item",
},
{
name: "arg3",
type: CliParameterType.BOOLEAN,
needsValue: false,
},
{
name: "arg4",
type: CliParameterType.ARRAY,
needsValue: true,
},
],
() => {
// Do nothing
},
"I love sussy sauces",
"emoji add --url https://site.com/image.png"
);
cliBuilder.registerCommand(cliCommand);
cliBuilder.displayHelp();
const loggedString = consoleLogSpy.mock.calls
.map(call => stripAnsi(call[0]))
.join("\n");
consoleLogSpy.mockRestore();
expect(loggedString).toContain("category1");
expect(loggedString).toContain(
" category2.................I love sussy sauces"
);
expect(loggedString).toContain(
" name..................Name of new item"
);
expect(loggedString).toContain(
" arg3..................(no description)"
);
expect(loggedString).toContain(
" arg4..................(no description)"
);
expect(loggedString).toContain(
" --delete-previous.....Also delete the previous item (optional)"
);
expect(loggedString).toContain(
" Example: emoji add --url https://site.com/image.png"
);
});
});

View file

@ -1,4 +1,4 @@
import data from "../config/config.toml";
import { MediaBackendType } from "media-manager";
export interface ConfigType {
database: {
@ -25,11 +25,36 @@ export interface ConfigType {
};
};
meilisearch: {
host: string;
port: number;
api_key: string;
enabled: boolean;
};
signups: {
tos_url: string;
rules: string[];
registration: boolean;
};
oidc: {
providers: {
name: string;
id: string;
url: string;
client_id: string;
client_secret: string;
icon: string;
}[];
};
http: {
base_url: string;
bind: string;
bind_port: string;
banned_ips: string[];
banned_user_agents: string[];
};
instance: {
@ -72,12 +97,13 @@ export interface ConfigType {
};
media: {
backend: string;
backend: MediaBackendType;
deduplicate_media: boolean;
conversion: {
convert_images: boolean;
convert_to: string;
};
local_uploads_folder: string;
};
s3: {
@ -129,6 +155,7 @@ export interface ConfigType {
logging: {
log_requests: boolean;
log_requests_verbose: boolean;
log_ip: boolean;
log_filters: boolean;
};
@ -153,6 +180,7 @@ export const configDefaults: ConfigType = {
bind_port: "8000",
base_url: "http://lysand.localhost:8000",
banned_ips: [],
banned_user_agents: [],
},
database: {
host: "localhost",
@ -176,6 +204,20 @@ export const configDefaults: ConfigType = {
enabled: false,
},
},
meilisearch: {
host: "localhost",
port: 1491,
api_key: "",
enabled: false,
},
signups: {
tos_url: "",
rules: [],
registration: false,
},
oidc: {
providers: [],
},
instance: {
banner: "",
description: "",
@ -190,12 +232,13 @@ export const configDefaults: ConfigType = {
username: "",
},
media: {
backend: "local",
backend: MediaBackendType.LOCAL,
deduplicate_media: true,
conversion: {
convert_images: false,
convert_to: "webp",
},
local_uploads_folder: "uploads",
},
email: {
send_on_report: false,
@ -311,6 +354,7 @@ export const configDefaults: ConfigType = {
logging: {
log_requests: false,
log_requests_verbose: false,
log_ip: false,
log_filters: true,
},
ratelimits: {
@ -319,16 +363,3 @@ export const configDefaults: ConfigType = {
},
custom_ratelimits: {},
};
export const getConfig = () => {
return {
...configDefaults,
...(data as ConfigType),
};
};
export const getHost = () => {
const url = new URL(getConfig().http.base_url);
return url.host;
};

View file

@ -0,0 +1,122 @@
/**
* @file index.ts
* @summary ConfigManager system to retrieve and modify system configuration
* @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml
* Fuses both and provides a way to retrieve individual values
*/
import { parse, stringify, type JsonMap } from "@iarna/toml";
import type { ConfigType } from "./config-type.type";
import { configDefaults } from "./config-type.type";
import merge from "merge-deep-ts";
export class ConfigManager {
constructor(
public config: {
configPathOverride?: string;
internalConfigPathOverride?: string;
}
) {}
/**
* @summary Reads the config files and returns the merge as a JSON object
* @returns {Promise<T = ConfigType>} The merged config file as a JSON object
*/
async getConfig<T = ConfigType>() {
const config = await this.readConfig<T>();
const internalConfig = await this.readInternalConfig<T>();
return this.mergeConfigs<T>(config, internalConfig);
}
getConfigPath() {
return (
this.config.configPathOverride ||
process.cwd() + "/config/config.toml"
);
}
getInternalConfigPath() {
return (
this.config.internalConfigPathOverride ||
process.cwd() + "/config/config.internal.toml"
);
}
/**
* @summary Reads the internal config file and returns it as a JSON object
* @returns {Promise<T = ConfigType>} The internal config file as a JSON object
*/
private async readInternalConfig<T = ConfigType>() {
const config = Bun.file(this.getInternalConfigPath());
if (!(await config.exists())) {
await Bun.write(config, "");
}
return this.parseConfig<T>(await config.text());
}
/**
* @summary Reads the config file and returns it as a JSON object
* @returns {Promise<T = ConfigType>} The config file as a JSON object
*/
private async readConfig<T = ConfigType>() {
const config = Bun.file(this.getConfigPath());
if (!(await config.exists())) {
throw new Error(
`Error while reading config at path ${this.getConfigPath()}: Config file not found`
);
}
return this.parseConfig<T>(await config.text());
}
/**
* @summary Parses a TOML string and returns it as a JSON object
* @param text The TOML string to parse
* @returns {T = ConfigType} The parsed TOML string as a JSON object
* @throws {Error} If the TOML string is invalid
* @private
*/
private parseConfig<T = ConfigType>(text: string) {
try {
// To all [Symbol] keys from the object
return JSON.parse(JSON.stringify(parse(text))) as T;
} catch (e: any) {
throw new Error(
`Error while parsing config at path ${this.getConfigPath()}: ${e}`
);
}
}
/**
* Writes changed values to the internal config
* @param config The new config object
*/
async writeConfig<T = ConfigType>(config: T) {
const path = this.getInternalConfigPath();
const file = Bun.file(path);
await Bun.write(
file,
`# THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT IT MANUALLY, EDIT THE STANDARD CONFIG.TOML INSTEAD.\n${stringify(
config as JsonMap
)}`
);
}
/**
* @summary Merges two config objects together, with
* the latter configs' values taking precedence
* @param configs
* @returns
*/
private mergeConfigs<T = ConfigType>(...configs: T[]) {
return merge(configs) as T;
}
}
export type { ConfigType };
export const defaultConfig = configDefaults;

View file

@ -0,0 +1,6 @@
{
"name": "config-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

@ -0,0 +1,96 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/config-manager/config-manager.test.ts
import { stringify } from "@iarna/toml";
import { ConfigManager } from "..";
import { describe, beforeEach, spyOn, it, expect } from "bun:test";
describe("ConfigManager", () => {
let configManager: ConfigManager;
beforeEach(() => {
configManager = new ConfigManager({
configPathOverride: "./config/config.toml",
internalConfigPathOverride: "./config/config.internal.toml",
});
});
it("should get the correct config path", () => {
expect(configManager.getConfigPath()).toEqual("./config/config.toml");
});
it("should get the correct internal config path", () => {
expect(configManager.getInternalConfigPath()).toEqual(
"./config/config.internal.toml"
);
});
it("should read the config file correctly", async () => {
const mockConfig = { key: "value" };
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () =>
new Promise(resolve => {
resolve(true);
}),
text: () =>
new Promise(resolve => {
resolve(stringify(mockConfig));
}),
}));
const config = await configManager.getConfig<typeof mockConfig>();
expect(config).toEqual(mockConfig);
});
it("should read the internal config file correctly", async () => {
const mockConfig = { key: "value" };
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () =>
new Promise(resolve => {
resolve(true);
}),
text: () =>
new Promise(resolve => {
resolve(stringify(mockConfig));
}),
}));
const config =
// @ts-expect-error Force call private function for testing
await configManager.readInternalConfig<typeof mockConfig>();
expect(config).toEqual(mockConfig);
});
it("should write to the internal config file correctly", async () => {
const mockConfig = { key: "value" };
spyOn(Bun, "write").mockImplementationOnce(
() =>
new Promise(resolve => {
resolve(10);
})
);
await configManager.writeConfig(mockConfig);
});
it("should merge configs correctly", () => {
const config1 = { key1: "value1", key2: "value2" };
const config2 = { key2: "newValue2", key3: "value3" };
// @ts-expect-error Force call private function for testing
const mergedConfig = configManager.mergeConfigs<Record<string, string>>(
config1,
config2
);
expect(mergedConfig).toEqual({
key1: "value1",
key2: "newValue2",
key3: "value3",
});
});
});

View file

@ -0,0 +1,171 @@
import type { BunFile } from "bun";
import { appendFile } from "fs/promises";
export enum LogLevel {
DEBUG = "debug",
INFO = "info",
WARNING = "warning",
ERROR = "error",
CRITICAL = "critical",
}
/**
* Class for handling logging to disk or to stdout
* @param output BunFile of output (can be a normal file or something like Bun.stdout)
*/
export class LogManager {
constructor(private output: BunFile) {
void this.write(
`--- INIT LogManager at ${new Date().toISOString()} ---`
);
}
/**
* Logs a message to the output
* @param level Importance of the log
* @param entity Emitter of the log
* @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log
*/
async log(
level: LogLevel,
entity: string,
message: string,
showTimestamp = true
) {
await this.write(
`${showTimestamp ? new Date().toISOString() + " " : ""}[${level.toUpperCase()}] ${entity}: ${message}`
);
}
private async write(text: string) {
if (this.output == Bun.stdout) {
await Bun.write(Bun.stdout, text + "\n");
} else {
if (!this.output.name) {
throw new Error(`Output file doesnt exist (and isnt stdout)`);
}
await appendFile(this.output.name, text + "\n");
}
}
/**
* Logs an error to the output, wrapper for log
* @param level Importance of the log
* @param entity Emitter of the log
* @param error Error to log
*/
async logError(level: LogLevel, entity: string, error: Error) {
await this.log(level, entity, error.message);
}
/**
* Logs a request to the output
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
let string = ip ? `${ip}: ` : "";
string += `${req.method} ${req.url}`;
if (logAllDetails) {
string += `\n`;
string += ` [Headers]\n`;
// Pretty print headers
for (const [key, value] of req.headers.entries()) {
string += ` ${key}: ${value}\n`;
}
// Pretty print body
string += ` [Body]\n`;
const content_type = req.headers.get("Content-Type");
if (content_type && content_type.includes("application/json")) {
const json = await req.json();
const stringified = JSON.stringify(json, null, 4)
.split("\n")
.map(line => ` ${line}`)
.join("\n");
string += `${stringified}\n`;
} else if (
content_type &&
(content_type.includes("application/x-www-form-urlencoded") ||
content_type.includes("multipart/form-data"))
) {
const formData = await req.formData();
for (const [key, value] of formData.entries()) {
if (value.toString().length < 300) {
string += ` ${key}: ${value.toString()}\n`;
} else {
string += ` ${key}: <${value.toString().length} bytes>\n`;
}
}
} else {
const text = await req.text();
string += ` ${text}\n`;
}
}
await this.log(LogLevel.INFO, "Request", string);
}
}
/**
* Outputs to multiple LogManager instances at once
*/
export class MultiLogManager {
constructor(private logManagers: LogManager[]) {}
/**
* Logs a message to all logManagers
* @param level Importance of the log
* @param entity Emitter of the log
* @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log
*/
async log(
level: LogLevel,
entity: string,
message: string,
showTimestamp = true
) {
for (const logManager of this.logManagers) {
await logManager.log(level, entity, message, showTimestamp);
}
}
/**
* Logs an error to all logManagers
* @param level Importance of the log
* @param entity Emitter of the log
* @param error Error to log
*/
async logError(level: LogLevel, entity: string, error: Error) {
for (const logManager of this.logManagers) {
await logManager.logError(level, entity, error);
}
}
/**
* Logs a request to all logManagers
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
for (const logManager of this.logManagers) {
await logManager.logRequest(req, ip, logAllDetails);
}
}
/**
* Create a MultiLogManager from multiple LogManager instances
* @param logManagers LogManager instances to use
* @returns
*/
static fromLogManagers(...logManagers: LogManager[]) {
return new MultiLogManager(logManagers);
}
}

View file

@ -0,0 +1,6 @@
{
"name": "log-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { }
}

View file

@ -0,0 +1,231 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts
import { LogManager, LogLevel, MultiLogManager } from "../index";
import type fs from "fs/promises";
import {
describe,
it,
beforeEach,
expect,
jest,
mock,
type Mock,
test,
} from "bun:test";
import type { BunFile } from "bun";
describe("LogManager", () => {
let logManager: LogManager;
let mockOutput: BunFile;
let mockAppend: Mock<typeof fs.appendFile>;
beforeEach(async () => {
mockOutput = Bun.file("test.log");
mockAppend = jest.fn();
await mock.module("fs/promises", () => ({
appendFile: mockAppend,
}));
logManager = new LogManager(mockOutput);
});
it("should initialize and write init log", () => {
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("--- INIT LogManager at")
);
});
it("should log message with timestamp", async () => {
await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("[INFO] TestEntity: Test message")
);
});
it("should log message without timestamp", async () => {
await logManager.log(
LogLevel.INFO,
"TestEntity",
"Test message",
false
);
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
"[INFO] TestEntity: Test message\n"
);
});
test.skip("should write to stdout", async () => {
logManager = new LogManager(Bun.stdout);
await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
const writeMock = jest.fn();
await mock.module("Bun", () => ({
stdout: Bun.stdout,
write: writeMock,
}));
expect(writeMock).toHaveBeenCalledWith(
Bun.stdout,
expect.stringContaining("[INFO] TestEntity: Test message")
);
});
it("should throw error if output file does not exist", () => {
mockAppend.mockImplementationOnce(() => {
return Promise.reject(
new Error("Output file doesnt exist (and isnt stdout)")
);
});
expect(
logManager.log(LogLevel.INFO, "TestEntity", "Test message")
).rejects.toThrow(Error);
});
it("should log error message", async () => {
const error = new Error("Test error");
await logManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("[ERROR] TestEntity: Test error")
);
});
it("should log basic request details", async () => {
const req = new Request("http://localhost/test", { method: "GET" });
await logManager.logRequest(req, "127.0.0.1");
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("127.0.0.1: GET http://localhost/test")
);
});
describe("Request logger", () => {
it("should log all request details for JSON content type", async () => {
const req = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ test: "value" }),
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: application/json
[Body]
{
"test": "value"
}
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(expectedLog)
);
});
it("should log all request details for text content type", async () => {
const req = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "Test body",
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: text/plain
[Body]
Test body
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(expectedLog)
);
});
it("should log all request details for FormData content-type", async () => {
const formData = new FormData();
formData.append("test", "value");
const req = new Request("http://localhost/test", {
method: "POST",
body: formData,
});
await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers]
content-type: multipart/form-data; boundary=${
req.headers.get("Content-Type")?.split("boundary=")[1] ?? ""
}
[Body]
test: value
`;
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining(
expectedLog.replace("----", expect.any(String))
)
);
});
});
});
describe("MultiLogManager", () => {
let multiLogManager: MultiLogManager;
let mockLogManagers: LogManager[];
let mockLog: jest.Mock;
let mockLogError: jest.Mock;
let mockLogRequest: jest.Mock;
beforeEach(() => {
mockLog = jest.fn();
mockLogError = jest.fn();
mockLogRequest = jest.fn();
mockLogManagers = [
{
log: mockLog,
logError: mockLogError,
logRequest: mockLogRequest,
},
{
log: mockLog,
logError: mockLogError,
logRequest: mockLogRequest,
},
] as unknown as LogManager[];
multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers);
});
it("should log message to all logManagers", async () => {
await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog).toHaveBeenCalledWith(
LogLevel.INFO,
"TestEntity",
"Test message",
true
);
});
it("should log error to all logManagers", async () => {
const error = new Error("Test error");
await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockLogError).toHaveBeenCalledTimes(2);
expect(mockLogError).toHaveBeenCalledWith(
LogLevel.ERROR,
"TestEntity",
error
);
});
it("should log request to all logManagers", async () => {
const req = new Request("http://localhost/test", { method: "GET" });
await multiLogManager.logRequest(req, "127.0.0.1", true);
expect(mockLogRequest).toHaveBeenCalledTimes(2);
expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true);
});
});

View file

@ -0,0 +1,64 @@
import type { ConvertableMediaFormats } from "../media-converter";
import { MediaConverter } from "../media-converter";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConfigType } from "config-manager";
export class LocalMediaBackend extends MediaBackend {
constructor(config: ConfigType) {
super(config, MediaBackendType.LOCAL);
}
public async addFile(file: File) {
if (this.shouldConvertImages(this.config)) {
const fileExtension = file.name.split(".").pop();
const mediaConverter = new MediaConverter(
fileExtension as ConvertableMediaFormats,
this.config.media.conversion
.convert_to as ConvertableMediaFormats
);
file = await mediaConverter.convert(file);
}
const hash = await new MediaHasher().getMediaHash(file);
const newFile = Bun.file(
`${this.config.media.local_uploads_folder}/${hash}`
);
if (await newFile.exists()) {
throw new Error("File already exists");
}
await Bun.write(newFile, file);
return {
uploadedFile: file,
path: `./uploads/${file.name}`,
hash: hash,
};
}
public async getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
if (!filename) return null;
return this.getFile(filename);
}
public async getFile(filename: string): Promise<File | null> {
const file = Bun.file(
`${this.config.media.local_uploads_folder}/${filename}`
);
if (!(await file.exists())) return null;
return new File([await file.arrayBuffer()], filename, {
type: file.type,
lastModified: file.lastModified,
});
}
}

View file

@ -0,0 +1,69 @@
import { S3Client } from "@bradenmacdonald/s3-lite-client";
import type { ConvertableMediaFormats } from "../media-converter";
import { MediaConverter } from "../media-converter";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConfigType } from "config-manager";
export class S3MediaBackend extends MediaBackend {
constructor(
config: ConfigType,
private s3Client = new S3Client({
endPoint: config.s3.endpoint,
useSSL: true,
region: config.s3.region || "auto",
bucket: config.s3.bucket_name,
accessKey: config.s3.access_key,
secretKey: config.s3.secret_access_key,
})
) {
super(config, MediaBackendType.S3);
}
public async addFile(file: File) {
if (this.shouldConvertImages(this.config)) {
const fileExtension = file.name.split(".").pop();
const mediaConverter = new MediaConverter(
fileExtension as ConvertableMediaFormats,
this.config.media.conversion
.convert_to as ConvertableMediaFormats
);
file = await mediaConverter.convert(file);
}
const hash = await new MediaHasher().getMediaHash(file);
await this.s3Client.putObject(file.name, file.stream(), {
size: file.size,
});
return {
uploadedFile: file,
hash: hash,
};
}
public async getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
if (!filename) return null;
return this.getFile(filename);
}
public async getFile(filename: string): Promise<File | null> {
try {
await this.s3Client.statObject(filename);
} catch {
return null;
}
const file = await this.s3Client.getObject(filename);
return new File([await file.arrayBuffer()], filename, {
type: file.headers.get("Content-Type") || "undefined",
});
}
}

BIN
packages/media-manager/bun.lockb Executable file

Binary file not shown.

View file

@ -0,0 +1,2 @@
[install.scopes]
"@jsr" = "https://npm.jsr.io"

View file

@ -0,0 +1,101 @@
import type { ConfigType } from "config-manager";
export enum MediaBackendType {
LOCAL = "local",
S3 = "s3",
}
interface UploadedFileMetadata {
uploadedFile: File;
path?: string;
hash: string;
}
export class MediaHasher {
/**
* Returns the SHA-256 hash of a file in hex format
* @param media The file to hash
* @returns The SHA-256 hash of the file in hex format
*/
public async getMediaHash(media: File) {
const hash = new Bun.SHA256()
.update(await media.arrayBuffer())
.digest("hex");
return hash;
}
}
export class MediaBackend {
constructor(
public config: ConfigType,
public backend: MediaBackendType
) {}
static async fromBackendType(
backend: MediaBackendType,
config: ConfigType
): Promise<MediaBackend> {
switch (backend) {
case MediaBackendType.LOCAL:
return new (await import("./backends/local")).LocalMediaBackend(
config
);
case MediaBackendType.S3:
return new (await import("./backends/s3")).S3MediaBackend(
config
);
default:
throw new Error(`Unknown backend type: ${backend as any}`);
}
}
public getBackendType() {
return this.backend;
}
public shouldConvertImages(config: ConfigType) {
return config.media.conversion.convert_images;
}
/**
* Fetches file from backend from SHA-256 hash
* @param file SHA-256 hash of wanted file
* @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database
* @returns The file as a File object
*/
public getFileByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
file: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
databaseHashFetcher: (sha256: string) => Promise<string>
): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
/**
* Fetches file from backend from filename
* @param filename File name
* @returns The file as a File object
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getFile(filename: string): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
/**
* Adds file to backend
* @param file File to add
* @returns Metadata about the uploaded file
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public addFile(file: File): Promise<UploadedFileMetadata> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
}

View file

@ -0,0 +1,94 @@
/**
* @packageDocumentation
* @module MediaManager
* @description Handles media conversion between formats
*/
import sharp from "sharp";
export enum ConvertableMediaFormats {
PNG = "png",
WEBP = "webp",
JPEG = "jpeg",
JPG = "jpg",
AVIF = "avif",
JXL = "jxl",
HEIF = "heif",
}
/**
* Handles media conversion between formats
*/
export class MediaConverter {
constructor(
public fromFormat: ConvertableMediaFormats,
public toFormat: ConvertableMediaFormats
) {}
/**
* Returns whether the media is convertable
* @returns Whether the media is convertable
*/
public isConvertable() {
return (
this.fromFormat !== this.toFormat &&
Object.values(ConvertableMediaFormats).includes(this.fromFormat)
);
}
/**
* Returns the file name with the extension replaced
* @param fileName File name to replace
* @returns File name with extension replaced
*/
private getReplacedFileName(fileName: string) {
return this.extractFilenameFromPath(fileName).replace(
new RegExp(`\\.${this.fromFormat}$`),
`.${this.toFormat}`
);
}
/**
* Extracts the filename from a path
* @param path Path to extract filename from
* @returns Extracted filename
*/
private extractFilenameFromPath(path: string) {
// Don't count escaped slashes as path separators
const pathParts = path.split(/(?<!\\)\//);
return pathParts[pathParts.length - 1];
}
/**
* Converts media to the specified format
* @param media Media to convert
* @returns Converted media
*/
public async convert(media: File) {
if (!this.isConvertable()) {
return media;
}
const sharpCommand = sharp(await media.arrayBuffer());
// Calculate newFilename before changing formats to prevent errors with jpg files
const newFilename = this.getReplacedFileName(media.name);
if (this.fromFormat === ConvertableMediaFormats.JPG) {
this.fromFormat = ConvertableMediaFormats.JPEG;
}
if (this.toFormat === ConvertableMediaFormats.JPG) {
this.toFormat = ConvertableMediaFormats.JPEG;
}
const convertedBuffer = await sharpCommand[this.toFormat]().toBuffer();
// Convert the buffer to a BlobPart
const buffer = new Blob([convertedBuffer]);
return new File([buffer], newFilename, {
type: `image/${this.toFormat}`,
lastModified: Date.now(),
});
}
}

View file

@ -0,0 +1,6 @@
{
"name": "media-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client" }
}

View file

@ -0,0 +1,276 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/backends/s3.test.ts
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { S3Client } from "@bradenmacdonald/s3-lite-client";
import { beforeEach, describe, jest, it, expect, spyOn } from "bun:test";
import { S3MediaBackend } from "../backends/s3";
import type { ConfigType } from "config-manager";
import { ConvertableMediaFormats, MediaConverter } from "../media-converter";
import { LocalMediaBackend } from "../backends/local";
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
describe("MediaBackend", () => {
let mediaBackend: MediaBackend;
let mockConfig: ConfigType;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
},
},
} as ConfigType;
mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3);
});
it("should initialize with correct backend type", () => {
expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
});
describe("fromBackendType", () => {
it("should return a LocalMediaBackend instance for LOCAL backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.LOCAL,
mockConfig
);
expect(backend).toBeInstanceOf(LocalMediaBackend);
});
it("should return a S3MediaBackend instance for S3 backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.S3,
{
s3: {
endpoint: "localhost:4566",
region: "us-east-1",
bucket_name: "test-bucket",
access_key: "test-access",
public_url: "test",
secret_access_key: "test-secret",
},
} as ConfigType
);
expect(backend).toBeInstanceOf(S3MediaBackend);
});
it("should throw an error for unknown backend type", () => {
expect(
MediaBackend.fromBackendType("unknown" as any, mockConfig)
).rejects.toThrow("Unknown backend type: unknown");
});
});
it("should check if images should be converted", () => {
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true);
mockConfig.media.conversion.convert_images = false;
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false);
});
it("should throw error when calling getFileByHash", () => {
const mockHash = "test-hash";
const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg");
expect(
mediaBackend.getFileByHash(mockHash, databaseHashFetcher)
).rejects.toThrow(Error);
});
it("should throw error when calling getFile", () => {
const mockFilename = "test.jpg";
expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error);
});
it("should throw error when calling addFile", () => {
const mockFile = new File([""], "test.jpg");
expect(mediaBackend.addFile(mockFile)).rejects.toThrow();
});
});
describe("S3MediaBackend", () => {
let s3MediaBackend: S3MediaBackend;
let mockS3Client: Partial<S3Client>;
let mockConfig: DeepPartial<ConfigType>;
let mockFile: File;
let mockMediaHasher: MediaHasher;
beforeEach(() => {
mockConfig = {
s3: {
endpoint: "http://localhost:4566",
region: "us-east-1",
bucket_name: "test-bucket",
access_key: "test-access-key",
secret_access_key: "test-secret-access-key",
public_url: "test",
},
media: {
conversion: {
convert_to: ConvertableMediaFormats.PNG,
},
},
};
mockFile = new File([new TextEncoder().encode("test")], "test.jpg");
mockMediaHasher = new MediaHasher();
mockS3Client = {
putObject: jest.fn().mockResolvedValue({}),
statObject: jest.fn().mockResolvedValue({}),
getObject: jest.fn().mockResolvedValue({
blob: jest.fn().mockResolvedValue(new Blob()),
headers: new Headers({ "Content-Type": "image/jpeg" }),
}),
} as Partial<S3Client>;
s3MediaBackend = new S3MediaBackend(
mockConfig as ConfigType,
mockS3Client as S3Client
);
});
it("should initialize with correct type", () => {
expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
});
it("should add file", async () => {
const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const result = await s3MediaBackend.addFile(mockFile);
expect(result.uploadedFile).toEqual(mockFile);
expect(result.hash).toHaveLength(64);
expect(mockS3Client.putObject).toHaveBeenCalledWith(
mockFile.name,
expect.any(ReadableStream),
{ size: mockFile.size }
);
});
it("should get file by hash", async () => {
const mockHash = "test-hash";
const mockFilename = "test.jpg";
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
mockS3Client.statObject = jest.fn().mockResolvedValue({});
mockS3Client.getObject = jest.fn().mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
headers: new Headers({ "Content-Type": "image/jpeg" }),
});
const file = await s3MediaBackend.getFileByHash(
mockHash,
databaseHashFetcher
);
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
it("should get file", async () => {
const mockFilename = "test.jpg";
mockS3Client.statObject = jest.fn().mockResolvedValue({});
mockS3Client.getObject = jest.fn().mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
headers: new Headers({ "Content-Type": "image/jpeg" }),
});
const file = await s3MediaBackend.getFile(mockFilename);
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
});
describe("LocalMediaBackend", () => {
let localMediaBackend: LocalMediaBackend;
let mockConfig: ConfigType;
let mockFile: File;
let mockMediaHasher: MediaHasher;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
convert_to: ConvertableMediaFormats.PNG,
},
local_uploads_folder: "./uploads",
},
} as ConfigType;
mockFile = Bun.file(__dirname + "/megamind.jpg") as unknown as File;
mockMediaHasher = new MediaHasher();
localMediaBackend = new LocalMediaBackend(mockConfig);
});
it("should initialize with correct type", () => {
expect(localMediaBackend.getBackendType()).toEqual(
MediaBackendType.LOCAL
);
});
it("should add file", async () => {
const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const mockMediaConverter = new MediaConverter(
ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG
);
spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile);
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(false),
}));
spyOn(Bun, "write").mockImplementationOnce(() =>
Promise.resolve(mockFile.size)
);
const result = await localMediaBackend.addFile(mockFile);
expect(result.uploadedFile).toEqual(mockFile);
expect(result.path).toEqual(`./uploads/megamind.png`);
expect(result.hash).toHaveLength(64);
});
it("should get file by hash", async () => {
const mockHash = "test-hash";
const mockFilename = "test.jpg";
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(true),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
type: "image/jpeg",
lastModified: 123456789,
}));
const file = await localMediaBackend.getFileByHash(
mockHash,
databaseHashFetcher
);
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
it("should get file", async () => {
const mockFilename = "test.jpg";
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(true),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
type: "image/jpeg",
lastModified: 123456789,
}));
const file = await localMediaBackend.getFile(mockFilename);
expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg");
});
});

View file

@ -0,0 +1,65 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts
import { describe, it, expect, beforeEach } from "bun:test";
import { MediaConverter, ConvertableMediaFormats } from "../media-converter";
describe("MediaConverter", () => {
let mediaConverter: MediaConverter;
beforeEach(() => {
mediaConverter = new MediaConverter(
ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG
);
});
it("should initialize with correct formats", () => {
expect(mediaConverter.fromFormat).toEqual(ConvertableMediaFormats.JPG);
expect(mediaConverter.toFormat).toEqual(ConvertableMediaFormats.PNG);
});
it("should check if media is convertable", () => {
expect(mediaConverter.isConvertable()).toBe(true);
mediaConverter.toFormat = ConvertableMediaFormats.JPG;
expect(mediaConverter.isConvertable()).toBe(false);
});
it("should replace file name extension", () => {
const fileName = "test.jpg";
const expectedFileName = "test.png";
// Written like this because it's a private function
expect(mediaConverter["getReplacedFileName"](fileName)).toEqual(
expectedFileName
);
});
describe("Filename extractor", () => {
it("should extract filename from path", () => {
const path = "path/to/test.jpg";
const expectedFileName = "test.jpg";
expect(mediaConverter["extractFilenameFromPath"](path)).toEqual(
expectedFileName
);
});
it("should handle escaped slashes", () => {
const path = "path/to/test\\/test.jpg";
const expectedFileName = "test\\/test.jpg";
expect(mediaConverter["extractFilenameFromPath"](path)).toEqual(
expectedFileName
);
});
});
it("should convert media", async () => {
const file = Bun.file(__dirname + "/megamind.jpg");
const convertedFile = await mediaConverter.convert(
file as unknown as File
);
expect(convertedFile.name).toEqual("megamind.png");
expect(convertedFile.type).toEqual(
`image/${ConvertableMediaFormats.PNG}`
);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

@ -0,0 +1,170 @@
/**
* RequestParser
* @file index.ts
* @module request-parser
* @description Parses Request object into a JavaScript object based on the content type
*/
/**
* RequestParser
* Parses Request object into a JavaScript object
* based on the Content-Type header
* @param request Request object
* @returns JavaScript object of type T
*/
export class RequestParser {
constructor(public request: Request) {}
/**
* Parse request body into a JavaScript object
* @returns JavaScript object of type T
* @throws Error if body is invalid
*/
async toObject<T>() {
try {
switch (await this.determineContentType()) {
case "application/json":
return this.parseJson<T>();
case "application/x-www-form-urlencoded":
return this.parseFormUrlencoded<T>();
case "multipart/form-data":
return this.parseFormData<T>();
default:
return this.parseQuery<T>();
}
} catch {
return {} as T;
}
}
/**
* Determine body content type
* If there is no Content-Type header, automatically
* guess content type. Cuts off after ";" character
* @returns Content-Type header value, or empty string if there is no body
* @throws Error if body is invalid
* @private
*/
private async determineContentType() {
if (this.request.headers.get("Content-Type")) {
return (
this.request.headers.get("Content-Type")?.split(";")[0] ?? ""
);
}
// Check if body is valid JSON
try {
await this.request.json();
return "application/json";
} catch {
// This is not JSON
}
// Check if body is valid FormData
try {
await this.request.formData();
return "multipart/form-data";
} catch {
// This is not FormData
}
if (this.request.body) {
throw new Error("Invalid body");
}
// If there is no body, return query parameters
return "";
}
/**
* Parse FormData body into a JavaScript object
* @returns JavaScript object of type T
* @private
* @throws Error if body is invalid
*/
private async parseFormData<T>(): Promise<Partial<T>> {
const formData = await this.request.formData();
const result: Partial<T> = {};
for (const [key, value] of formData.entries()) {
if (value instanceof File) {
result[key as keyof T] = value as any;
} else if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as any[]).push(value);
} else {
result[key as keyof T] = value as any;
}
}
return result;
}
/**
* Parse application/x-www-form-urlencoded body into a JavaScript object
* @returns JavaScript object of type T
* @private
* @throws Error if body is invalid
*/
private async parseFormUrlencoded<T>(): Promise<Partial<T>> {
const formData = await this.request.formData();
const result: Partial<T> = {};
for (const [key, value] of formData.entries()) {
if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as any[]).push(value);
} else {
result[key as keyof T] = value as any;
}
}
return result;
}
/**
* Parse JSON body into a JavaScript object
* @returns JavaScript object of type T
* @private
* @throws Error if body is invalid
*/
private async parseJson<T>(): Promise<Partial<T>> {
try {
return (await this.request.json()) as T;
} catch {
return {};
}
}
/**
* Parse query parameters into a JavaScript object
* @private
* @throws Error if body is invalid
* @returns JavaScript object of type T
*/
private parseQuery<T>(): Partial<T> {
const result: Partial<T> = {};
const url = new URL(this.request.url);
for (const [key, value] of url.searchParams.entries()) {
if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as string[]).push(value);
} else {
result[key as keyof T] = value as any;
}
}
return result;
}
}

View file

@ -0,0 +1,6 @@
{
"name": "request-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

@ -0,0 +1,158 @@
import { describe, it, expect, test } from "bun:test";
import { RequestParser } from "..";
describe("RequestParser", () => {
describe("Should parse query parameters correctly", () => {
test("With text parameters", async () => {
const request = new Request(
"http://localhost?param1=value1&param2=value2"
);
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
test("With Array", async () => {
const request = new Request(
"http://localhost?test[]=value1&test[]=value2"
);
const result = await new RequestParser(request).toObject<{
test: string[];
}>();
expect(result.test).toEqual(["value1", "value2"]);
});
test("With both at once", async () => {
const request = new Request(
"http://localhost?param1=value1&param2=value2&test[]=value1&test[]=value2"
);
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
test: string[];
}>();
expect(result).toEqual({
param1: "value1",
param2: "value2",
test: ["value1", "value2"],
});
});
});
it("should parse JSON body correctly", async () => {
const request = new Request("http://localhost", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ param1: "value1", param2: "value2" }),
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
it("should handle invalid JSON body", async () => {
const request = new Request("http://localhost", {
headers: { "Content-Type": "application/json" },
body: "invalid json",
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({});
});
describe("should parse form data correctly", () => {
test("With basic text parameters", async () => {
const formData = new FormData();
formData.append("param1", "value1");
formData.append("param2", "value2");
const request = new Request("http://localhost", {
method: "POST",
body: formData,
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
test("With File object", async () => {
const file = new File(["content"], "filename.txt", {
type: "text/plain",
});
const formData = new FormData();
formData.append("file", file);
const request = new Request("http://localhost", {
method: "POST",
body: formData,
});
const result = await new RequestParser(request).toObject<{
file: File;
}>();
expect(result.file).toBeInstanceOf(File);
expect(await result.file?.text()).toEqual("content");
});
test("With Array", async () => {
const formData = new FormData();
formData.append("test[]", "value1");
formData.append("test[]", "value2");
const request = new Request("http://localhost", {
method: "POST",
body: formData,
});
const result = await new RequestParser(request).toObject<{
test: string[];
}>();
expect(result.test).toEqual(["value1", "value2"]);
});
test("With all three at once", async () => {
const file = new File(["content"], "filename.txt", {
type: "text/plain",
});
const formData = new FormData();
formData.append("param1", "value1");
formData.append("param2", "value2");
formData.append("file", file);
formData.append("test[]", "value1");
formData.append("test[]", "value2");
const request = new Request("http://localhost", {
method: "POST",
body: formData,
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
file: File;
test: string[];
}>();
expect(result).toEqual({
param1: "value1",
param2: "value2",
file: file,
test: ["value1", "value2"],
});
});
test("URL Encoded", async () => {
const request = new Request("http://localhost", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: "param1=value1&param2=value2",
});
const result = await new RequestParser(request).toObject<{
param1: string;
param2: string;
}>();
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
});
});

10
pages/App.vue Normal file
View file

@ -0,0 +1,10 @@
<script setup>
// Import Tailwind style reset
import '@unocss/reset/tailwind-compat.css'
</script>
<template>
<suspense>
<router-view></router-view>
</suspense>
</template>

View file

@ -0,0 +1,30 @@
<template>
<div class="flex items-center justify-between">
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">{{ label }}</label>
</div>
<div class="mt-2">
<input v-bind="$attrs" @input="checkValid" :class="['block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6',
(isInvalid || error) && 'invalid:!ring-red-600 invalid:ring-2']">
<span v-if="isInvalid || error" class="mt-1 text-xs text-red-600">{{ error ? error : `${label} is invalid` }}</span>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{
label: string;
error?: string;
}>();
const isInvalid = ref(false);
const checkValid = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checkValidity()) {
isInvalid.value = false;
} else {
isInvalid.value = true;
}
}
</script>

BIN
pages/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

16
pages/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lysand</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View file

@ -1,445 +0,0 @@
<!DOCTYPE html>
<head>
<title>Login with Lysand</title>
{{STYLES}}
<style>
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
2. [UnoCSS]: allow to override the default border color with css var `--un-default-border-color`
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: var(--un-default-border-color, #e5e7eb);
/* 2 */
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
*/
html {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font family by default.
2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%;
/* 1 */
font-weight: inherit;
/* 1 */
line-height: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
/* 1 */
background-color: transparent;
/* 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
</style>
<meta charset="utf-8">
</head>
<body>
<div class="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8">
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" method="POST" action="{{URL}}">
<div>
<label for="email" class="block text-sm font-medium leading-6 text-gray-900">Email address</label>
<div class="mt-2">
<input id="email" name="email" type="email" autocomplete="email" required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
</div>
</div>
<div>
<div class="flex items-center justify-between">
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
</div>
<div class="mt-2">
<input id="password" name="password" type="password" autocomplete="current-password" required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
</div>
</div>
<div>
<button type="submit"
class="flex w-full justify-center rounded-md !bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:!bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign
in</button>
</div>
</form>
</div>
</div>
</body>

16
pages/main.ts Normal file
View file

@ -0,0 +1,16 @@
import { createApp } from "vue";
import "./style.css";
import "virtual:uno.css";
import { createRouter, createWebHistory } from "vue-router";
import App from "./App.vue";
import routes from "./routes";
const router = createRouter({
history: createWebHistory(),
routes: routes,
});
const app = createApp(App);
app.use(router);
app.mount("#app");

44
pages/pages/index.vue Normal file
View file

@ -0,0 +1,44 @@
<template>
<div class="bg-white">
<div class="relative isolate px-6 pt-14 lg:px-8">
<div class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80"
aria-hidden="true">
<div class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" />
</div>
<div class="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56">
<div class="hidden sm:mb-8 sm:flex sm:justify-center">
<div
class="relative rounded px-3 py-1 text-sm leading-6 text-gray-600 ring-1 ring-gray-900/10 hover:ring-gray-900/20">
You are using <a href="#" class="font-semibold text-indigo-600">Lysand {{ version }}</a>
</div>
</div>
<div class="text-center">
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">Welcome to Lysand</h1>
<p class="mt-6 text-lg leading-8 text-gray-600">You can login to this server by pointing any Mastodon
client at <strong class="font-bold">{{ location.host }}</strong></p>
<div class="mt-10 flex items-center justify-center gap-6 md:flex-row flex-col">
<a href="https://github.com/lysand-org/lysand" target="_blank"
class="rounded-md w-full bg-indigo-600 ring-indigo-600 ring-2 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Read
the docs</a>
<a href="https://lysand.org" target="_blank"
class="rounded-md w-full ring-indigo-600 ring-2 bg-white px-3.5 py-2.5 text-sm font-semibold text-indigo-500 shadow-sm hover:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">About
the Lysand Protocol</a>
</div>
</div>
</div>
<div class="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]"
aria-hidden="true">
<div class="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]"
style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const location = window.location;
const version = __VERSION__;
</script>

View file

@ -0,0 +1,79 @@
<template>
<div class="flex min-h-screen relative flex-col justify-center px-6 py-12 lg:px-8">
<div class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
<div class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" />
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" method="POST"
:action="`/auth/login?redirect_uri=${redirect_uri}&response_type=${response_type}&client_id=${client_id}&scope=${scope}`">
<div>
<h1 class="font-bold text-2xl text-center tracking-tight">Login to your account</h1>
</div>
<div v-if="error && error !== 'undefined'" class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
<h3 class="font-bold">An error occured:</h3>
<p>{{ error }}</p>
</div>
<div>
<LoginInput label="Email" id="email" name="email" type="email" autocomplete="email" required />
</div>
<div>
<LoginInput label="Password" id="password" name="password" type="password"
autocomplete="current-password" required />
</div>
<div v-if="oauthProviders && oauthProviders.length > 0" class="w-full flex flex-col gap-3">
<h2 class="text-sm text-gray-700">Or sign in with</h2>
<div class="grid grid-cols-2 gap-4 w-full">
<a v-for="provider of oauthProviders" :key="provider.id"
:href="`/oauth/authorize-external?issuer=${provider.id}&redirect_uri=${redirect_uri}&response_type=${response_type}&clientId=${client_id}&scope=${scope}`"
class="flex flex-row justify-center rounded ring-1 gap-2 p-2 ring-black/10 hover:shadow duration-200">
<img :src="provider.icon" :alt="provider.name" class="w-6 h-6" />
<div class="flex flex-col gap-0 justify-center">
<h3 class="font-bold">{{ provider.name }}</h3>
</div>
</a>
</div>
</div>
<div>
<button type="submit"
class="flex w-full justify-center rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:shadow-lg duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign
in</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import LoginInput from "../../components/LoginInput.vue"
import { onMounted, ref } from 'vue';
const query = useRoute().query;
const redirect_uri = query.redirect_uri;
const response_type = query.response_type;
const client_id = query.client_id;
const scope = query.scope;
const error = decodeURIComponent(query.error as string);
const oauthProviders = ref<{
name: string;
icon: string;
id: string
}[] | null>(null);
const getOauthProviders = async () => {
const response = await fetch('/oauth/providers');
return await response.json() as any;
}
onMounted(async () => {
oauthProviders.value = await getOauthProviders();
})
</script>

View file

@ -0,0 +1,149 @@
<template>
<div class="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 relative">
<div class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
<div class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" />
</div>
<div v-if="instanceInfo.registrations" class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form ref="form" class="space-y-6" method="POST" action="" @submit.prevent="registerUser">
<div>
<h1 class="font-bold text-2xl text-center tracking-tight">Register for an account</h1>
</div>
<div>
<LoginInput label="Email" id="email" name="email" type="email" autocomplete="email" required />
</div>
<div v-if="errors['email']" v-for="error of errors['email']"
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
<h3 class="font-bold">An error occured:</h3>
<p>{{ error.description }}</p>
</div>
<div>
<LoginInput label="Username" id="username" name="username" type="text" autocomplete="username"
required />
</div>
<div v-if="errors['username']" v-for="error of errors['username']"
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
<h3 class="font-bold">An error occured:</h3>
<p>{{ error.description }}</p>
</div>
<div>
<LoginInput label="Password" id="password" name="password" type="password" autocomplete="" required
:spellcheck="false" :error="!passwordsMatch ? `Passwords dont match` : ``" :value="password1"
@input="password1 = $event.target.value" />
</div>
<div v-if="errors['password']" v-for="error of errors['password']"
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
<h3 class="font-bold">An error occured:</h3>
<p>{{ error.description }}</p>
</div>
<div>
<LoginInput label="Confirm password" id="password2" name="password2" type="password" autocomplete=""
required :spellcheck="false" :error="!passwordsMatch ? `Passwords dont match` : ``"
:value="password2" @input="password2 = $event.target.value" />
</div>
<div>
<label for="comment" class="block text-sm font-medium leading-6 text-gray-900">Why do you want to
join?</label>
<div class="mt-2">
<textarea rows="4" required :value="reason" @input="reason = ($event.target as any).value"
name="comment" id="comment"
class="block w-full rounded-md px-2 border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" />
</div>
</div>
<div v-if="errors['reason']" v-for="error of errors['reason']"
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
<h3 class="font-bold">An error occured:</h3>
<p>{{ error.description }}</p>
</div>
<div class="">
<input type="checkbox" :value="tosAccepted" @input="tosAccepted = Boolean(($event.target as any).value)"
class="rounded mr-1 align-middle mb-0.5" /> <span class="text-sm">I agree to the
terms and
conditions
of this
server, available <a class="underline font-bold" target="_blank"
:href="instanceInfo.tos_url">here</a></span>
</div>
<div v-if="errors['agreement']" v-for="error of errors['agreement']"
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
<h3 class="font-bold">An error occured:</h3>
<p>{{ error.description }}</p>
</div>
<div>
<button type="submit" :disabled="!passwordsMatch || !tosAccepted"
class="flex w-full justify-center disabled:opacity-50 disabled:hover:shadow-none rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:shadow-lg duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign
in</button>
</div>
</form>
</div>
<div v-else>
<h1 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl text-center">Registrations are disabled
</h1>
<p class="mt-6 text-lg leading-8 text-gray-600 text-center">Ask this instance's admin to enable them in config!
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { APIInstance } from "~types/entities/instance";
import LoginInput from "../../components/LoginInput.vue"
import { computed, ref, watch } from 'vue';
const instanceInfo = await fetch("/api/v1/instance").then(res => res.json()) as APIInstance & {
tos_url: string
};
const errors = ref<{
[key: string]: {
error: string;
description: string;
}[];
}>({});
const password1 = ref<string>("");
const password2 = ref<string>("");
const tosAccepted = ref<boolean>(false);
const reason = ref<string>("");
const passwordsMatch = computed(() => password1.value === password2.value);
const registerUser = (e: Event) => {
e.preventDefault();
const formData = new FormData();
formData.append("email", (e.target as any).email.value);
formData.append("password", (e.target as any).password.value);
formData.append("username", (e.target as any).username.value);
formData.append("reason", reason.value);
formData.append("locale", "en")
formData.append("agreement", "true");
// @ts-ignore
fetch("/api/v1/accounts", {
method: "POST",
body: formData,
}).then(async res => {
if (res.status === 422) {
errors.value = (await res.json() as any).details;
console.log(errors.value)
} else {
// @ts-ignore
window.location.href = "/register/success";
}
}).catch(async err => {
console.error(err);
})
}
</script>

View file

@ -0,0 +1,14 @@
<template>
<div class="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 relative">
<div class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
<div class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" />
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl text-center">Registration was a success!
</h1>
<p class="mt-6 text-lg leading-8 text-gray-600 text-center"> You can now login to your account in any Mastodon
client </p>
</div>
</div>
</template>

12
pages/routes.ts Normal file
View file

@ -0,0 +1,12 @@
import type { RouteRecordRaw } from "vue-router";
import indexVue from "./pages/index.vue";
import authorizeVue from "./pages/oauth/authorize.vue";
import registerIndexVue from "./pages/register/index.vue";
import successVue from "./pages/register/success.vue";
export default [
{ path: "/", component: indexVue },
{ path: "/oauth/authorize", component: authorizeVue },
{ path: "/register", component: registerIndexVue },
{ path: "/register/success", component: successVue },
] as RouteRecordRaw[];

0
pages/style.css Normal file
View file

View file

@ -1,155 +0,0 @@
/* layer: preflights */
*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgba(0,0,0,0);--un-ring-shadow:0 0 rgba(0,0,0,0);--un-shadow-inset: ;--un-shadow:0 0 rgba(0,0,0,0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgba(0,0,0,0);--un-ring-shadow:0 0 rgba(0,0,0,0);--un-shadow-inset: ;--un-shadow:0 0 rgba(0,0,0,0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}
[type='text'], [type='email'], [type='url'], [type='password'], [type='number'], [type='date'], [type='datetime-local'], [type='month'], [type='search'], [type='tel'], [type='time'], [type='week'], [multiple], textarea, select { appearance: none;
background-color: #fff;
border-color: #6b7280;
border-width: 1px;
border-radius: 0;
padding-top: 0.5rem;
padding-right: 0.75rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
font-size: 1rem;
line-height: 1.5rem;
--un-shadow: 0 0 #0000; }
[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { outline: 2px solid transparent;
outline-offset: 2px;
--un-ring-inset: var(--un-empty,/*!*/ /*!*/);
--un-ring-offset-width: 0px;
--un-ring-offset-color: #fff;
--un-ring-color: #2563eb;
--un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);
--un-ring-shadow: var(--un-ring-inset) 0 0 0 calc(1px + var(--un-ring-offset-width)) var(--un-ring-color);
box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);
border-color: #2563eb; }
input::placeholder, textarea::placeholder { color: #6b7280;
opacity: 1; }
::-webkit-datetime-edit-fields-wrapper { padding: 0; }
::-webkit-date-and-time-value { min-height: 1.5em; }
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-top: 0;
padding-bottom: 0; }
select { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
print-color-adjust: exact; }
[multiple] { background-image: initial;
background-position: initial;
background-repeat: unset;
background-size: initial;
padding-right: 0.75rem;
print-color-adjust: unset; }
[type='checkbox'], [type='radio'] { appearance: none;
padding: 0;
print-color-adjust: exact;
display: inline-block;
vertical-align: middle;
background-origin: border-box;
user-select: none;
flex-shrink: 0;
height: 1rem;
width: 1rem;
color: #2563eb;
background-color: #fff;
border-color: #6b7280;
border-width: 1px;
--un-shadow: 0 0 #0000; }
[type='checkbox'] { border-radius: 0; }
[type='radio'] { border-radius: 100%; }
[type='checkbox']:focus, [type='radio']:focus { outline: 2px solid transparent;
outline-offset: 2px;
--un-ring-inset: var(--un-empty,/*!*/ /*!*/);
--un-ring-offset-width: 2px;
--un-ring-offset-color: #fff;
--un-ring-color: #2563eb;
--un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);
--un-ring-shadow: var(--un-ring-inset) 0 0 0 calc(2px + var(--un-ring-offset-width)) var(--un-ring-color);
box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow); }
[type='checkbox']:checked, [type='radio']:checked { border-color: transparent;
background-color: currentColor;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat; }
[type='checkbox']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); }
[type='radio']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); }
[type='checkbox']:checked:hover, [type='checkbox']:checked:focus, [type='radio']:checked:hover, [type='radio']:checked:focus { border-color: transparent;
background-color: currentColor; }
[type='checkbox']:indeterminate { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");
border-color: transparent;
background-color: currentColor;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat; }
[type='checkbox']:indeterminate:hover, [type='checkbox']:indeterminate:focus { border-color: transparent;
background-color: currentColor; }
[type='file'] { background: unset;
border-color: inherit;
border-width: 0;
border-radius: 0;
padding: 0;
font-size: unset;
line-height: inherit; }
[type='file']:focus { outline: 1px solid ButtonText , 1px auto -webkit-focus-ring-color; }
/* layer: default */
.visible{visibility:visible;}
.relative{position:relative;}
.mt-10{margin-top:2.5rem;}
.mt-2{margin-top:0.5rem;}
.block{display:block;}
.contents{display:contents;}
.list-item{display:list-item;}
.hidden{display:none;}
.h6{height:1.5rem;}
.min-h-screen{min-height:100vh;}
.w-full{width:100%;}
.flex{display:flex;}
.flex-col{flex-direction:column;}
.table{display:table;}
.border-collapse{border-collapse:collapse;}
.transform{transform:translateX(var(--un-translate-x)) translateY(var(--un-translate-y)) translateZ(var(--un-translate-z)) rotate(var(--un-rotate)) rotateX(var(--un-rotate-x)) rotateY(var(--un-rotate-y)) rotateZ(var(--un-rotate-z)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) scaleZ(var(--un-scale-z));}
.resize{resize:both;}
.items-center{align-items:center;}
.justify-center{justify-content:center;}
.justify-between{justify-content:space-between;}
.space-y-6>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(1.5rem * var(--un-space-y-reverse));}
.border{border-width:1px;}
.border-0{border-width:0;}
.rounded-md{border-radius:0.375rem;}
.\!bg-indigo-600{--un-bg-opacity:1 !important;background-color:rgba(79,70,229,var(--un-bg-opacity)) !important;}
.hover\:\!bg-indigo-500:hover{--un-bg-opacity:1 !important;background-color:rgba(99,102,241,var(--un-bg-opacity)) !important;}
.px-3{padding-left:0.75rem;padding-right:0.75rem;}
.px-6{padding-left:1.5rem;padding-right:1.5rem;}
.py-1\.5{padding-top:0.375rem;padding-bottom:0.375rem;}
.py-12{padding-top:3rem;padding-bottom:3rem;}
.text-sm{font-size:0.875rem;line-height:1.25rem;}
.font-medium{font-weight:500;}
.font-semibold{font-weight:600;}
.leading-6{line-height:1.5rem;}
.text-gray-900{--un-text-opacity:1;color:rgba(17,24,39,var(--un-text-opacity));}
.text-white{--un-text-opacity:1;color:rgba(255,255,255,var(--un-text-opacity));}
.placeholder\:text-gray-400::placeholder{--un-text-opacity:1;color:rgba(156,163,175,var(--un-text-opacity));}
.underline{text-decoration-line:underline;}
.tab{-moz-tab-size:4;-o-tab-size:4;tab-size:4;}
.shadow-sm{--un-shadow:var(--un-shadow-inset) 0 1px 2px 0 var(--un-shadow-color, rgba(0,0,0,0.05));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.focus-visible\:outline-2:focus-visible{outline-width:2px;}
.focus-visible\:outline-indigo-600:focus-visible{--un-outline-color-opacity:1;outline-color:rgba(79,70,229,var(--un-outline-color-opacity));}
.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px;}
.outline{outline-style:solid;}
.focus-visible\:outline:focus-visible{outline-style:solid;}
.ring-1{--un-ring-width:1px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.focus\:ring-2:focus{--un-ring-width:2px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.ring-gray-300{--un-ring-opacity:1;--un-ring-color:rgba(209,213,219,var(--un-ring-opacity));}
.focus\:ring-indigo-600:focus{--un-ring-opacity:1;--un-ring-color:rgba(79,70,229,var(--un-ring-opacity));}
.ring-inset{--un-ring-inset:inset;}
.focus\:ring-inset:focus{--un-ring-inset:inset;}
@media (min-width: 640px){
.sm\:mx-auto{margin-left:auto;margin-right:auto;}
.sm\:max-w-sm{max-width:24rem;}
.sm\:w-full{width:100%;}
.sm\:text-sm{font-size:0.875rem;line-height:1.25rem;}
.sm\:leading-6{line-height:1.5rem;}
}
@media (min-width: 1024px){
.lg\:px-8{padding-left:2rem;padding-right:2rem;}
}

1
pages/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

35
pages/vite.config.ts Normal file
View file

@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import UnoCSS from "unocss/vite";
import vue from "@vitejs/plugin-vue";
import pkg from "../package.json";
export default defineConfig({
base: "/",
build: {
outDir: "./dist",
},
// main.ts is in pages/ directory
resolve: {
alias: {
vue: "vue/dist/vue.esm-bundler",
},
},
server: {
hmr: {
clientPort: 5173,
},
},
define: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
__VERSION__: JSON.stringify(pkg.version),
},
ssr: {
noExternal: ["@prisma/client"],
},
plugins: [
UnoCSS({
mode: "global",
}),
vue(),
],
});

View file

@ -1,17 +1,11 @@
import { ConfigManager } from "config-manager";
// Proxies all `bunx prisma` commands with an environment variable
const config = await new ConfigManager({}).getConfig();
import { getConfig } from "@config";
process.stdout.write(
`postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}\n`
);
const args = process.argv.slice(2);
const config = getConfig();
const { stdout } = Bun.spawn(["bunx", "prisma", ...args], {
env: {
...process.env,
DATABASE_URL: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,
},
});
// Show stdout
const text = await new Response(stdout).text();
console.log(text);
// Ends
process.exit(0);

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Status" ADD COLUMN "contentSource" TEXT NOT NULL DEFAULT '';

View file

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "OpenIdLoginFlow" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"codeVerifier" TEXT NOT NULL,
CONSTRAINT "OpenIdLoginFlow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OpenIdAccount" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"userId" UUID,
"serverId" TEXT NOT NULL,
"issuerId" TEXT NOT NULL,
CONSTRAINT "OpenIdAccount_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "OpenIdAccount" ADD CONSTRAINT "OpenIdAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `issuerId` to the `OpenIdLoginFlow` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "OpenIdLoginFlow" ADD COLUMN "applicationId" UUID,
ADD COLUMN "issuerId" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "OpenIdLoginFlow" ADD CONSTRAINT "OpenIdLoginFlow_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[client_id]` on the table `Application` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Application_client_id_key" ON "Application"("client_id");

View file

@ -0,0 +1,40 @@
-- AlterTable
ALTER TABLE "Instance" ADD COLUMN "disableAutomoderation" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "disableAutomoderation" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "ModerationData" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"statusId" UUID NOT NULL,
"creatorId" UUID NOT NULL,
"note" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModerationData_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Flag" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"statusId" UUID NOT NULL,
"userId" UUID NOT NULL,
"flagType" TEXT NOT NULL DEFAULT 'other',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Flag_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ModerationData" ADD CONSTRAINT "ModerationData_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "Status"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModerationData" ADD CONSTRAINT "ModerationData_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Flag" ADD CONSTRAINT "Flag_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "Status"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Flag" ADD CONSTRAINT "Flag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,76 @@
/*
Warnings:
- You are about to drop the column `statusId` on the `Flag` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `Flag` table. All the data in the column will be lost.
- You are about to drop the `ModerationData` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Flag" DROP CONSTRAINT "Flag_statusId_fkey";
-- DropForeignKey
ALTER TABLE "Flag" DROP CONSTRAINT "Flag_userId_fkey";
-- DropForeignKey
ALTER TABLE "ModerationData" DROP CONSTRAINT "ModerationData_creatorId_fkey";
-- DropForeignKey
ALTER TABLE "ModerationData" DROP CONSTRAINT "ModerationData_statusId_fkey";
-- AlterTable
ALTER TABLE "Flag" DROP COLUMN "statusId",
DROP COLUMN "userId",
ADD COLUMN "flaggeStatusId" UUID,
ADD COLUMN "flaggedUserId" UUID;
-- DropTable
DROP TABLE "ModerationData";
-- CreateTable
CREATE TABLE "ModNote" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"notedStatusId" UUID,
"notedUserId" UUID,
"modId" UUID NOT NULL,
"note" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ModNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModTag" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"taggedStatusId" UUID,
"taggedUserId" UUID,
"modId" UUID NOT NULL,
"tag" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ModTag_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_notedStatusId_fkey" FOREIGN KEY ("notedStatusId") REFERENCES "Status"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_notedUserId_fkey" FOREIGN KEY ("notedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_modId_fkey" FOREIGN KEY ("modId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModTag" ADD CONSTRAINT "ModTag_taggedStatusId_fkey" FOREIGN KEY ("taggedStatusId") REFERENCES "Status"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModTag" ADD CONSTRAINT "ModTag_taggedUserId_fkey" FOREIGN KEY ("taggedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModTag" ADD CONSTRAINT "ModTag_modId_fkey" FOREIGN KEY ("modId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Flag" ADD CONSTRAINT "Flag_flaggeStatusId_fkey" FOREIGN KEY ("flaggeStatusId") REFERENCES "Status"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Flag" ADD CONSTRAINT "Flag_flaggedUserId_fkey" FOREIGN KEY ("flaggedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -10,16 +10,17 @@ datasource db {
}
model Application {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
name String
website String?
vapid_key String?
client_id String
secret String
scopes String
redirect_uris String
statuses Status[] // One to many relation with Status
tokens Token[] // One to many relation with Token
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
name String
website String?
vapid_key String?
client_id String @unique
secret String
scopes String
redirect_uris String
statuses Status[] // One to many relation with Status
tokens Token[] // One to many relation with Token
openIdLoginFlows OpenIdLoginFlow[]
}
model Emoji {
@ -36,14 +37,15 @@ model Emoji {
}
model Instance {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
base_url String
name String
version String
logo Json
emojis Emoji[] // One to many relation with Emoji
statuses Status[] // One to many relation with Status
users User[] // One to many relation with User
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
base_url String
name String
version String
logo Json
emojis Emoji[] // One to many relation with Emoji
statuses Status[] // One to many relation with Status
users User[] // One to many relation with User
disableAutomoderation Boolean @default(false)
}
model Like {
@ -103,6 +105,7 @@ model Status {
isReblog Boolean
content String @default("")
contentType String @default("text/plain")
contentSource String @default("")
visibility String
inReplyToPost Status? @relation("StatusToStatusReply", fields: [inReplyToPostId], references: [id], onDelete: SetNull)
inReplyToPostId String? @db.Uuid
@ -123,6 +126,44 @@ model Status {
pinnedBy User[] @relation("UserPinnedNotes")
attachments Attachment[]
relatedNotifications Notification[]
flags Flag[]
modNotes ModNote[]
modTags ModTag[]
}
model ModNote {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
notedStatus Status? @relation(fields: [notedStatusId], references: [id], onDelete: Cascade)
notedStatusId String? @db.Uuid
notedUser User? @relation("ModNoteToUser", fields: [notedUserId], references: [id], onDelete: Cascade)
notedUserId String? @db.Uuid
mod User @relation("ModNoteToMod", fields: [modId], references: [id], onDelete: Cascade)
modId String @db.Uuid
note String
createdAt DateTime @default(now())
}
model ModTag {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
taggedStatus Status? @relation(fields: [taggedStatusId], references: [id], onDelete: Cascade)
taggedStatusId String? @db.Uuid
taggedUser User? @relation("ModNoteToTaggedUser", fields: [taggedUserId], references: [id], onDelete: Cascade)
taggedUserId String? @db.Uuid
mod User @relation("ModTagToMod", fields: [modId], references: [id], onDelete: Cascade)
modId String @db.Uuid
tag String
createdAt DateTime @default(now())
}
// Used to tag notes and accounts with automatic moderation infractions
model Flag {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
flaggedStatus Status? @relation(fields: [flaggeStatusId], references: [id], onDelete: Cascade)
flaggeStatusId String? @db.Uuid
flaggedUser User? @relation(fields: [flaggedUserId], references: [id], onDelete: Cascade)
flaggedUserId String? @db.Uuid
flagType String @default("other")
createdAt DateTime @default(now())
}
model Token {
@ -138,6 +179,14 @@ model Token {
applicationId String? @db.Uuid
}
model OpenIdLoginFlow {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
codeVerifier String
issuerId String
application Application? @relation(fields: [applicationId], references: [id], onDelete: Cascade)
applicationId String? @db.Uuid
}
model Attachment {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
url String
@ -169,36 +218,51 @@ model Notification {
}
model User {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
uri String @unique
username String @unique
displayName String
password String? // Nullable
email String? @unique // Nullable
note String @default("")
isAdmin Boolean @default(false)
endpoints Json? // Nullable
source Json
avatar String
header String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isBot Boolean @default(false)
isLocked Boolean @default(false)
isDiscoverable Boolean @default(false)
sanctions String[] @default([])
publicKey String
privateKey String? // Nullable
relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship
relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship
instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance
instanceId String? @db.Uuid
pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status
emojis Emoji[] // Many to many relation with Emoji
statuses Status[] @relation("UserStatuses") // One to many relation with Status
tokens Token[] // One to many relation with Token
likes Like[] @relation("UserLiked") // One to many relation with Like
statusesMentioned Status[] // Many to many relation with Status
notifications Notification[] // One to many relation with Notification
notified Notification[] @relation("NotificationToNotified") // One to many relation with Notification
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
uri String @unique
username String @unique
displayName String
password String? // Nullable
email String? @unique // Nullable
note String @default("")
isAdmin Boolean @default(false)
endpoints Json? // Nullable
source Json
avatar String
header String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isBot Boolean @default(false)
isLocked Boolean @default(false)
isDiscoverable Boolean @default(false)
sanctions String[] @default([])
publicKey String
privateKey String? // Nullable
relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship
relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship
instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance
instanceId String? @db.Uuid
pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status
emojis Emoji[] // Many to many relation with Emoji
statuses Status[] @relation("UserStatuses") // One to many relation with Status
tokens Token[] // One to many relation with Token
likes Like[] @relation("UserLiked") // One to many relation with Like
statusesMentioned Status[] // Many to many relation with Status
notifications Notification[] // One to many relation with Notification
notified Notification[] @relation("NotificationToNotified") // One to many relation with Notification
linkedOpenIdAccounts OpenIdAccount[] // One to many relation with OpenIdAccount
flags Flag[]
modNotes ModNote[] @relation("ModNoteToUser")
modTags ModTag[] @relation("ModNoteToTaggedUser")
disableAutomoderation Boolean @default(false)
createdModTags ModTag[] @relation("ModTagToMod")
createdModNotes ModNote[] @relation("ModNoteToMod")
}
model OpenIdAccount {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
User User? @relation(fields: [userId], references: [id])
userId String? @db.Uuid
serverId String // ID on the authorization server
issuerId String
}

188
routes.ts Normal file
View file

@ -0,0 +1,188 @@
import type { RouteHandler } from "~server/api/routes.type";
import type { APIRouteMeta } from "~types/api";
const serverPath = process.cwd() + "/server/api";
// Why are these routes specified manually instead of using Bun's FileSystemRouter?
// This is to allow for compilation of the routes, so that we can minify them and
// node_modules in production
export const rawRoutes = {
"/api/v1/accounts": await import(serverPath + "/api/v1/accounts/index.ts"),
"/api/v1/accounts/familiar_followers": await import(
serverPath + "/api/v1/accounts/familiar_followers/index.ts"
),
"/api/v1/accounts/relationships": await import(
serverPath + "/api/v1/accounts/relationships/index.ts"
),
"/api/v1/accounts/search": await import(
serverPath + "/api/v1/accounts/search/index.ts"
),
"/api/v1/accounts/update_credentials": await import(
serverPath + "/api/v1/accounts/update_credentials/index.ts"
),
"/api/v1/accounts/verify_credentials": await import(
serverPath + "/api/v1/accounts/verify_credentials/index.ts"
),
"/api/v1/apps": await import(serverPath + "/api/v1/apps/index.ts"),
"/api/v1/apps/verify_credentials": await import(
serverPath + "/api/v1/apps/verify_credentials/index.ts"
),
"/api/v1/blocks": await import(serverPath + "/api/v1/blocks/index.ts"),
"/api/v1/custom_emojis": await import(
serverPath + "/api/v1/custom_emojis/index.ts"
),
"/api/v1/favourites": await import(
serverPath + "/api/v1/favourites/index.ts"
),
"/api/v1/follow_requests": await import(
serverPath + "/api/v1/follow_requests/index.ts"
),
"/api/v1/instance": await import(serverPath + "/api/v1/instance/index.ts"),
"/api/v1/media": await import(serverPath + "/api/v1/media/index.ts"),
"/api/v1/mutes": await import(serverPath + "/api/v1/mutes/index.ts"),
"/api/v1/notifications": await import(
serverPath + "/api/v1/notifications/index.ts"
),
"/api/v1/profile/avatar": await import(
serverPath + "/api/v1/profile/avatar.ts"
),
"/api/v1/profile/header": await import(
serverPath + "/api/v1/profile/header.ts"
),
"/api/v1/statuses": await import(serverPath + "/api/v1/statuses/index.ts"),
"/api/v1/timelines/home": await import(
serverPath + "/api/v1/timelines/home.ts"
),
"/api/v1/timelines/public": await import(
serverPath + "/api/v1/timelines/public.ts"
),
"/api/v2/media": await import(serverPath + "/api/v2/media/index.ts"),
"/api/v2/search": await import(serverPath + "/api/v2/search/index.ts"),
"/auth/login": await import(serverPath + "/auth/login/index.ts"),
"/nodeinfo/2.0": await import(serverPath + "/nodeinfo/2.0/index.ts"),
"/oauth/authorize-external": await import(
serverPath + "/oauth/authorize-external/index.ts"
),
"/oauth/providers": await import(serverPath + "/oauth/providers/index.ts"),
"/oauth/token": await import(serverPath + "/oauth/token/index.ts"),
"/api/v1/accounts/[id]": await import(
serverPath + "/api/v1/accounts/[id]/index.ts"
),
"/api/v1/accounts/[id]/block": await import(
serverPath + "/api/v1/accounts/[id]/block.ts"
),
"/api/v1/accounts/[id]/follow": await import(
serverPath + "/api/v1/accounts/[id]/follow.ts"
),
"/api/v1/accounts/[id]/followers": await import(
serverPath + "/api/v1/accounts/[id]/followers.ts"
),
"/api/v1/accounts/[id]/following": await import(
serverPath + "/api/v1/accounts/[id]/following.ts"
),
"/api/v1/accounts/[id]/mute": await import(
serverPath + "/api/v1/accounts/[id]/mute.ts"
),
"/api/v1/accounts/[id]/note": await import(
serverPath + "/api/v1/accounts/[id]/note.ts"
),
"/api/v1/accounts/[id]/pin": await import(
serverPath + "/api/v1/accounts/[id]/pin.ts"
),
"/api/v1/accounts/[id]/remove_from_followers": await import(
serverPath + "/api/v1/accounts/[id]/remove_from_followers.ts"
),
"/api/v1/accounts/[id]/statuses": await import(
serverPath + "/api/v1/accounts/[id]/statuses.ts"
),
"/api/v1/accounts/[id]/unblock": await import(
serverPath + "/api/v1/accounts/[id]/unblock.ts"
),
"/api/v1/accounts/[id]/unfollow": await import(
serverPath + "/api/v1/accounts/[id]/unfollow.ts"
),
"/api/v1/accounts/[id]/unmute": await import(
serverPath + "/api/v1/accounts/[id]/unmute.ts"
),
"/api/v1/accounts/[id]/unpin": await import(
serverPath + "/api/v1/accounts/[id]/unpin.ts"
),
"/api/v1/follow_requests/[account_id]/authorize": await import(
serverPath + "/api/v1/follow_requests/[account_id]/authorize.ts"
),
"/api/v1/follow_requests/[account_id]/reject": await import(
serverPath + "/api/v1/follow_requests/[account_id]/reject.ts"
),
"/api/v1/media/[id]": await import(
serverPath + "/api/v1/media/[id]/index.ts"
),
"/api/v1/statuses/[id]": await import(
serverPath + "/api/v1/statuses/[id]/index.ts"
),
"/api/v1/statuses/[id]/context": await import(
serverPath + "/api/v1/statuses/[id]/context.ts"
),
"/api/v1/statuses/[id]/favourite": await import(
serverPath + "/api/v1/statuses/[id]/favourite.ts"
),
"/api/v1/statuses/[id]/favourited_by": await import(
serverPath + "/api/v1/statuses/[id]/favourited_by.ts"
),
"/api/v1/statuses/[id]/pin": await import(
serverPath + "/api/v1/statuses/[id]/pin.ts"
),
"/api/v1/statuses/[id]/reblog": await import(
serverPath + "/api/v1/statuses/[id]/reblog.ts"
),
"/api/v1/statuses/[id]/reblogged_by": await import(
serverPath + "/api/v1/statuses/[id]/reblogged_by.ts"
),
"/api/v1/statuses/[id]/source": await import(
serverPath + "/api/v1/statuses/[id]/source.ts"
),
"/api/v1/statuses/[id]/unfavourite": await import(
serverPath + "/api/v1/statuses/[id]/unfavourite.ts"
),
"/api/v1/statuses/[id]/unpin": await import(
serverPath + "/api/v1/statuses/[id]/unpin.ts"
),
"/api/v1/statuses/[id]/unreblog": await import(
serverPath + "/api/v1/statuses/[id]/unreblog.ts"
),
"/media/[id]": await import(serverPath + "/media/[id]/index.ts"),
"/oauth/callback/[issuer]": await import(
serverPath + "/oauth/callback/[issuer]/index.ts"
),
"/object/[uuid]": await import(serverPath + "/object/[uuid]/index.ts"),
"/users/[uuid]": await import(serverPath + "/users/[uuid]/index.ts"),
"/users/[uuid]/inbox": await import(
serverPath + "/users/[uuid]/inbox/index.ts"
),
"/users/[uuid]/outbox": await import(
serverPath + "/users/[uuid]/outbox/index.ts"
),
"/[...404]": await import(serverPath + "/[...404].ts"),
};
// Returns the route filesystem path when given a URL
export const routeMatcher = new Bun.FileSystemRouter({
style: "nextjs",
dir: process.cwd() + "/server/api",
});
export const matchRoute = <T = Record<string, never>>(url: string) => {
const route = routeMatcher.match(url);
if (!route) return { file: null, matchedRoute: null };
return {
// @ts-expect-error TypeScript parses this as a defined object instead of an arbitrarily editable route file
file: rawRoutes[route.name] as Promise<
| {
meta: APIRouteMeta;
default: RouteHandler<T>;
}
| undefined
>,
matchedRoute: route,
};
};

201
server.ts Normal file
View file

@ -0,0 +1,201 @@
import { errorResponse, jsonResponse } from "@response";
import { matches } from "ip-matching";
import { getFromRequest } from "~database/entities/User";
import type { ConfigManager, ConfigType } from "config-manager";
import type { LogManager, MultiLogManager } from "log-manager";
import { LogLevel } from "log-manager";
import { RequestParser } from "request-parser";
export const createServer = (
config: ConfigType,
configManager: ConfigManager,
logger: LogManager | MultiLogManager,
isProd: boolean
) =>
Bun.serve({
port: config.http.bind_port,
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
async fetch(req) {
// Check for banned IPs
const request_ip = this.requestIP(req)?.address ?? "";
for (const ip of config.http.banned_ips) {
try {
if (matches(ip, request_ip)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
}
} catch (e) {
console.error(`[-] Error while parsing banned IP "${ip}" `);
throw e;
}
}
// Check for banned user agents (regex)
const ua = req.headers.get("User-Agent") ?? "";
for (const agent of config.http.banned_user_agents) {
if (new RegExp(agent).test(ua)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
}
}
if (config.logging.log_requests) {
await logger.logRequest(
req,
config.logging.log_ip ? request_ip : undefined,
config.logging.log_requests_verbose
);
}
if (req.method === "OPTIONS") {
return jsonResponse({});
}
// If it isn't dynamically imported, it causes trouble with imports
// There shouldn't be a performance hit after bundling right?
const { matchRoute } = await import("~routes");
const { file: filePromise, matchedRoute } = matchRoute(req.url);
const file = await filePromise;
if (matchedRoute && file == undefined) {
await logger.log(
LogLevel.ERROR,
"Server",
`Route file ${matchedRoute.filePath} not found or not registered in the routes file`
);
return errorResponse("Route not found", 500);
}
if (
matchedRoute &&
matchedRoute.name !== "/[...404]" &&
file != undefined
) {
const meta = file.meta;
// Check for allowed requests
if (!meta.allowedMethods.includes(req.method as any)) {
return new Response(undefined, {
status: 405,
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
", "
)}`,
});
}
// TODO: Check for ratelimits
const auth = await getFromRequest(req);
// Check for authentication if required
if (meta.auth.required) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
} else if (
(meta.auth.requiredOnMethods ?? []).includes(
req.method as any
)
) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
}
let parsedRequest = {};
try {
parsedRequest = await new RequestParser(req).toObject();
} catch (e) {
await logger.logError(
LogLevel.ERROR,
"Server.RouteRequestParser",
e as Error
);
return new Response(undefined, {
status: 400,
statusText: "Bad request",
});
}
return await file.default(req.clone(), matchedRoute, {
auth,
configManager,
parsedRequest,
});
} else if (matchedRoute?.name === "/[...404]" || !matchedRoute) {
if (new URL(req.url).pathname.startsWith("/api")) {
return errorResponse("Route not found", 404);
}
// Proxy response from Vite at localhost:5173 if in development mode
if (isProd) {
if (new URL(req.url).pathname.startsWith("/assets")) {
const file = Bun.file(
`./pages/dist${new URL(req.url).pathname}`
);
// Serve from pages/dist/assets
if (await file.exists()) {
return new Response(file);
} else return errorResponse("Asset not found", 404);
}
if (new URL(req.url).pathname.startsWith("/api")) {
return errorResponse("Route not found", 404);
}
const file = Bun.file(`./pages/dist/index.html`);
// Serve from pages/dist
return new Response(file);
} else {
const proxy = await fetch(
req.url.replace(
config.http.base_url,
"http://localhost:5173"
)
).catch(async e => {
await logger.logError(
LogLevel.ERROR,
"Server.Proxy",
e as Error
);
await logger.log(
LogLevel.ERROR,
"Server.Proxy",
`The development Vite server is not running or the route is not found: ${req.url.replace(
config.http.base_url,
"http://localhost:5173"
)}`
);
return errorResponse("Route not found", 404);
});
if (
proxy.status !== 404 &&
!(await proxy.clone().text()).includes("404 Not Found")
) {
return proxy;
}
return errorResponse("Route not found", 404);
}
} else {
return errorResponse("Route not found", 404);
}
},
});

View file

@ -1,7 +1,5 @@
import { MatchedRoute } from "bun";
import { getConfig, getHost } from "@config";
import { xmlResponse } from "@response";
import { applyConfig } from "@api";
import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -16,18 +14,13 @@ export const meta = applyConfig({
});
/**
* Host meta endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const config = getConfig();
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
return xmlResponse(`
<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" template="${config.http.base_url}/.well-known/webfinger?resource={uri}"/>
</XRD>
</XRD>
`);
};
});

View file

@ -1,7 +1,5 @@
import { jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { getConfig } from "@config";
import { applyConfig } from "@api";
import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -15,14 +13,9 @@ export const meta = applyConfig({
route: "/.well-known/lysand",
});
/**
* Lysand instance metadata endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const config = getConfig();
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
// In the format acct:name@example.com
return jsonResponse({
type: "ServerMetadata",
@ -47,4 +40,4 @@ export default async (
website: "https://lysand.org",
// TODO: Add admins, moderators field
})
};
})

View file

@ -1,6 +1,4 @@
import { MatchedRoute } from "bun";
import { getConfig, getHost } from "@config";
import { applyConfig } from "@api";
import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -15,14 +13,8 @@ export const meta = applyConfig({
});
/**
* Redirect to /nodeinfo/2.0
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const config = getConfig();
export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig();
return new Response("", {
status: 301,
@ -30,4 +22,4 @@ export default async (
Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`,
},
});
};
});

View file

@ -1,7 +1,5 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { getConfig, getHost } from "@config";
import { applyConfig } from "@api";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -16,35 +14,30 @@ export const meta = applyConfig({
route: "/.well-known/webfinger",
});
/**
* ActivityPub WebFinger endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
// In the format acct:name@example.com
const resource = matchedRoute.query.resource;
const requestedUser = resource.split("acct:")[1];
const config = getConfig();
const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).hostname;
// Check if user is a local user
if (requestedUser.split("@")[1] !== getHost()) {
if (requestedUser.split("@")[1] !== host) {
return errorResponse("User is a remote user", 404);
}
const user = await client.user.findUnique({
where: { username: requestedUser.split("@")[0] },
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse({
subject: `acct:${user.username}@${getHost()}`,
subject: `acct:${user.username}@${host}`,
links: [
{
rel: "self",
@ -63,4 +56,4 @@ export default async (
}
]
})
};
});

21
server/api/[...404].ts Normal file
View file

@ -0,0 +1,21 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["POST", "GET", "PUT", "PATCH", "DELETE"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 100,
},
route: "/[...404]",
});
/**
* Default catch-all route, returns a 404 error.
*/
export default apiRoute(() => {
return errorResponse("This API route does not exist", 404);
});

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/**
* Blocks a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
});
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,15 +1,10 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -27,21 +22,18 @@ export const meta = applyConfig({
/**
* Follow a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute<{
reblogs?: boolean;
notify?: boolean;
languages?: string[];
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
const { languages, notify, reblogs } = await parseRequest<{
reblogs?: boolean;
notify?: boolean;
languages?: string[];
}>(req);
const { languages, notify, reblogs } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
@ -103,4 +95,4 @@ export default async (
});
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -0,0 +1,80 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { userRelations, userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 60,
duration: 60,
},
route: "/accounts/:id/followers",
auth: {
required: false,
},
});
/**
* Fetch all statuses for a user
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
// TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (!user) return errorResponse("User not found", 404);
const objects = await client.user.findMany({
where: {
relationships: {
some: {
subjectId: user.id,
following: true,
},
},
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: userRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
return jsonResponse(
await Promise.all(objects.map(object => userToAPI(object))),
200,
{
Link: linkHeader.join(", "),
}
);
});

View file

@ -0,0 +1,80 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { userRelations, userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
allowedMethods: ["GET"],
ratelimits: {
max: 60,
duration: 60,
},
route: "/accounts/:id/following",
auth: {
required: false,
},
});
/**
* Fetch all statuses for a user
*/
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
// TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
include: userRelations,
});
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (!user) return errorResponse("User not found", 404);
const objects = await client.user.findMany({
where: {
relationshipSubjects: {
some: {
ownerId: user.id,
following: true,
},
},
id: {
lt: max_id,
gt: min_id,
gte: since_id,
},
},
include: userRelations,
take: Number(limit),
orderBy: {
id: "desc",
},
});
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`
);
}
return jsonResponse(
await Promise.all(objects.map(object => userToAPI(object))),
200,
{
Link: linkHeader.join(", "),
}
);
});

View file

@ -1,12 +1,7 @@
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import type { UserWithRelations } from "~database/entities/User";
import {
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { userRelations, userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -24,13 +19,14 @@ export const meta = applyConfig({
/**
* Fetch a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
// Check if ID is valid UUID
if (!id.match(/^[0-9a-fA-F]{24}$/)) {
return errorResponse("Invalid ID", 404);
}
const { user } = await getFromRequest(req);
const { user } = extraData.auth;
let foundUser: UserWithRelations | null;
try {
@ -45,4 +41,4 @@ export default async (
if (!foundUser) return errorResponse("User not found", 404);
return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id));
};
});

View file

@ -1,15 +1,10 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -27,21 +22,18 @@ export const meta = applyConfig({
/**
* Mute a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute<{
notifications: boolean;
duration: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { notifications, duration } = await parseRequest<{
notifications: boolean;
duration: number;
}>(req);
const { notifications, duration } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
@ -97,4 +89,4 @@ export default async (
// TODO: Implement duration
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,15 +1,10 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -27,19 +22,16 @@ export const meta = applyConfig({
/**
* Sets a user note
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute<{
comment: string;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
const { comment } = await parseRequest<{
comment: string;
}>(req);
const { comment } = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
@ -87,4 +79,4 @@ export default async (
});
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/**
* Pin a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
});
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/**
* Removes an account from your followers list
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
@ -98,4 +91,4 @@ export default async (
}
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,9 +1,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { statusAndUserRelations, statusToAPI } from "~database/entities/Status";
import { userRelations } from "~database/entities/User";
import { applyConfig } from "@api";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -21,10 +20,18 @@ export const meta = applyConfig({
/**
* Fetch all statuses for a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: string;
only_media?: boolean;
exclude_replies?: boolean;
exclude_reblogs?: boolean;
// TODO: Add with_muted
pinned?: boolean;
tagged?: string;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
// TODO: Add pinned
@ -35,18 +42,7 @@ export default async (
limit = "20",
exclude_reblogs,
pinned,
}: {
max_id?: string;
since_id?: string;
min_id?: string;
limit?: string;
only_media?: boolean;
exclude_replies?: boolean;
exclude_reblogs?: boolean;
// TODO: Add with_muted
pinned?: boolean;
tagged?: string;
} = matchedRoute.query;
} = extraData.parsedRequest;
const user = await client.user.findUnique({
where: { id },
@ -131,4 +127,4 @@ export default async (
Link: linkHeader.join(", "),
}
);
};
});

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/**
* Blocks a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
});
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/**
* Unfollows a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
});
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/**
* Unmute a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
@ -86,4 +79,4 @@ export default async (
});
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import {
getFromRequest,
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/**
* Unpin a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req);
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
});
return jsonResponse(relationshipToAPI(relationship));
};
});

View file

@ -1,11 +1,6 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import {
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { userRelations, userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -23,14 +18,14 @@ export const meta = applyConfig({
/**
* Find familiar followers (followers of a user that you also follow)
*/
export default async (req: Request): Promise<Response> => {
const { user: self } = await getFromRequest(req);
export default apiRoute<{
id: string[];
}>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
const { "id[]": ids } = await parseRequest<{
"id[]": string[];
}>(req);
const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {
@ -67,4 +62,4 @@ export default async (req: Request): Promise<Response> => {
});
return jsonResponse(output.map(o => userToAPI(o)));
};
});

View file

@ -1,8 +1,6 @@
import { getConfig } from "@config";
import { parseRequest } from "@request";
import { jsonResponse } from "@response";
import { tempmailDomains } from "@tempmail";
import { applyConfig } from "@api";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User";
import ISO6391 from "iso-639-1";
@ -15,26 +13,32 @@ export const meta = applyConfig({
duration: 60,
},
auth: {
required: true,
required: false,
},
});
/**
* Creates a new user
*/
export default async (req: Request): Promise<Response> => {
export default apiRoute<{
username: string;
email: string;
password: string;
agreement: boolean;
locale: string;
reason: string;
}>(async (req, matchedRoute, extraData) => {
// TODO: Add Authorization check
const body = await parseRequest<{
username: string;
email: string;
password: string;
agreement: boolean;
locale: string;
reason: string;
}>(req);
const body = extraData.parsedRequest;
const config = getConfig();
const config = await extraData.configManager.getConfig();
if (!config.signups.registration) {
return jsonResponse(
{
error: "Registration is disabled",
},
422
);
}
const errors: {
details: Record<
@ -85,8 +89,8 @@ export default async (req: Request): Promise<Response> => {
// Check if username doesnt match filters
if (
config.filters.username_filters.some(
filter => body.username?.match(filter)
config.filters.username_filters.some(filter =>
body.username?.match(filter)
)
) {
errors.details.username.push({
@ -176,10 +180,13 @@ export default async (req: Request): Promise<Response> => {
.join(", ")}`
)
.join(", ");
return jsonResponse({
error: `Validation failed: ${errorsText}`,
details: errors.details,
});
return jsonResponse(
{
error: `Validation failed: ${errorsText}`,
details: errors.details,
},
422
);
}
await createNewLocalUser({
@ -191,4 +198,4 @@ export default async (req: Request): Promise<Response> => {
return new Response("", {
status: 200,
});
};
});

View file

@ -1,11 +1,9 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { getFromRequest } from "~database/entities/User";
import { applyConfig } from "@api";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({
@ -23,14 +21,14 @@ export const meta = applyConfig({
/**
* Find relationships
*/
export default async (req: Request): Promise<Response> => {
const { user: self } = await getFromRequest(req);
export default apiRoute<{
id: string[];
}>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401);
const { "id[]": ids } = await parseRequest<{
"id[]": string[];
}>(req);
const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) {
@ -64,4 +62,4 @@ export default async (req: Request): Promise<Response> => {
);
return jsonResponse(relationships.map(r => relationshipToAPI(r)));
};
});

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