mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
commit
8f8046186f
|
|
@ -14,4 +14,6 @@ helm-charts
|
|||
.idea
|
||||
coverage*
|
||||
uploads
|
||||
logs
|
||||
logs
|
||||
dist
|
||||
pages/dist
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
|
|
@ -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
6
.gitignore
vendored
|
|
@ -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
18
.vscode/launch.json
vendored
Normal 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
5
.vscode/settings.json
vendored
Normal 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
268
API.md
Normal 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[],
|
||||
}[],
|
||||
}
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
32
Dockerfile
32
Dockerfile
|
|
@ -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
185
README.md
|
|
@ -4,14 +4,14 @@
|
|||
|
||||
       [](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).
|
||||
|
|
|
|||
|
|
@ -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
43
build.ts
Normal 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!`);
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[install.scopes]
|
||||
"@jsr" = "https://npm.jsr.io"
|
||||
273
classes/media.ts
273
classes/media.ts
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
37
entrypoint.sh
Executable 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
206
index.ts
|
|
@ -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 };
|
||||
|
|
|
|||
53
package.json
53
package.json
|
|
@ -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
BIN
packages/cli-parser/bun.lockb
Executable file
Binary file not shown.
23
packages/cli-parser/cli-builder.type.ts
Normal file
23
packages/cli-parser/cli-builder.type.ts
Normal 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",
|
||||
}
|
||||
420
packages/cli-parser/index.ts
Normal file
420
packages/cli-parser/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
packages/cli-parser/package.json
Normal file
6
packages/cli-parser/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "arg-parser",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": { "strip-ansi": "^7.1.0" }
|
||||
}
|
||||
485
packages/cli-parser/tests/cli-builder.test.ts
Normal file
485
packages/cli-parser/tests/cli-builder.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
122
packages/config-manager/index.ts
Normal file
122
packages/config-manager/index.ts
Normal 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;
|
||||
6
packages/config-manager/package.json
Normal file
6
packages/config-manager/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "config-manager",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {}
|
||||
}
|
||||
96
packages/config-manager/tests/config-manager.test.ts
Normal file
96
packages/config-manager/tests/config-manager.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
171
packages/log-manager/index.ts
Normal file
171
packages/log-manager/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
packages/log-manager/package.json
Normal file
6
packages/log-manager/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "log-manager",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": { }
|
||||
}
|
||||
231
packages/log-manager/tests/log-manager.test.ts
Normal file
231
packages/log-manager/tests/log-manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
packages/media-manager/backends/local.ts
Normal file
64
packages/media-manager/backends/local.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
69
packages/media-manager/backends/s3.ts
Normal file
69
packages/media-manager/backends/s3.ts
Normal 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
BIN
packages/media-manager/bun.lockb
Executable file
Binary file not shown.
2
packages/media-manager/bunfig.toml
Normal file
2
packages/media-manager/bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[install.scopes]
|
||||
"@jsr" = "https://npm.jsr.io"
|
||||
101
packages/media-manager/index.ts
Normal file
101
packages/media-manager/index.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
94
packages/media-manager/media-converter.ts
Normal file
94
packages/media-manager/media-converter.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
6
packages/media-manager/package.json
Normal file
6
packages/media-manager/package.json
Normal 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" }
|
||||
}
|
||||
276
packages/media-manager/tests/media-backends.test.ts
Normal file
276
packages/media-manager/tests/media-backends.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
65
packages/media-manager/tests/media-manager.test.ts
Normal file
65
packages/media-manager/tests/media-manager.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
BIN
packages/media-manager/tests/megamind.jpg
Normal file
BIN
packages/media-manager/tests/megamind.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
170
packages/request-parser/index.ts
Normal file
170
packages/request-parser/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
packages/request-parser/package.json
Normal file
6
packages/request-parser/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "request-parser",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {}
|
||||
}
|
||||
158
packages/request-parser/tests/request-parser.test.ts
Normal file
158
packages/request-parser/tests/request-parser.test.ts
Normal 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¶m2=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¶m2=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¶m2=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
10
pages/App.vue
Normal 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>
|
||||
30
pages/components/LoginInput.vue
Normal file
30
pages/components/LoginInput.vue
Normal 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
BIN
pages/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
16
pages/index.html
Normal file
16
pages/index.html
Normal 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>
|
||||
445
pages/login.html
445
pages/login.html
|
|
@ -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
16
pages/main.ts
Normal 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
44
pages/pages/index.vue
Normal 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>
|
||||
79
pages/pages/oauth/authorize.vue
Normal file
79
pages/pages/oauth/authorize.vue
Normal 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>
|
||||
149
pages/pages/register/index.vue
Normal file
149
pages/pages/register/index.vue
Normal 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>
|
||||
14
pages/pages/register/success.vue
Normal file
14
pages/pages/register/success.vue
Normal 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
12
pages/routes.ts
Normal 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
0
pages/style.css
Normal file
155
pages/uno.css
155
pages/uno.css
|
|
@ -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
1
pages/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
35
pages/vite.config.ts
Normal file
35
pages/vite.config.ts
Normal 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(),
|
||||
],
|
||||
});
|
||||
22
prisma.ts
22
prisma.ts
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Status" ADD COLUMN "contentSource" TEXT NOT NULL DEFAULT '';
|
||||
20
prisma/migrations/20231206215508_add_openid/migration.sql
Normal file
20
prisma/migrations/20231206215508_add_openid/migration.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
188
routes.ts
Normal 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
201
server.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
`);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
21
server/api/[...404].ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
80
server/api/api/v1/accounts/[id]/followers.ts
Normal file
80
server/api/api/v1/accounts/[id]/followers.ts
Normal 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(", "),
|
||||
}
|
||||
);
|
||||
});
|
||||
80
server/api/api/v1/accounts/[id]/following.ts
Normal file
80
server/api/api/v1/accounts/[id]/following.ts
Normal 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(", "),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(", "),
|
||||
}
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue