diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b8cd2f4..19e9ce47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,8 +67,14 @@ 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) + +5. Generate the Prisma client: -5. Run migrations: +```bash +bun prisma generate +``` + +6. Run migrations: ```bash bun migrate @@ -130,9 +136,9 @@ When you are done with your changes, you can open a pull request. Please make su We use Bun's integrated testing system to write tests. You can find more information about it [here](https://bun.sh/docs/cli/test). It uses a Jest-like syntax. -Tests **must** be written for all API routes and all functions that are not trivial. If you are not sure whether you should write a test for something, you probably should. +Tests **should** be written for all API routes and all functions that are not trivial. If you are not sure whether you should write a test for something, you probably should. -To help with the creation of tests, you may find [GitHub Copilot](https://copilot.github.com/) useful (or some of its free alternatives like [Codeium](https://codeium.com/)). Please do not blindly copy the code that it generates, but use it as a starting point for your own tests. +To help with the creation of tests, you may find [GitHub Copilot](https://copilot.github.com/) useful (or some of its free alternatives like [Codeium](https://codeium.com/)). Please do not blindly copy the code that it generates, but use it as a starting point for your own tests. I recognize that writing tests is very tedious, which is why LLMs can come in handy. ### Writing documentation diff --git a/README.md b/README.md index 2cbc5774..b23635cf 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,14 @@ 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 project aims to be a fully featured social network, with a focus on privacy and security. 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 will implement 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. ## Features - [x] Inbound federation +- [x] Hyper fast (thousands of HTTP requests per second) - [x] S3 or local media storage - [x] Deduplication of uploaded files - [x] Federation limits @@ -23,10 +24,47 @@ This project aims to be a fully featured social network, with a focus on privacy - [x] Full regex-based filters for posts, users and media - [x] Custom emoji support - [x] Automatic image conversion to WebP or other formats +- [x] Scripting-compatible CLI with JSON and CSV outputs - [ ] Moderation tools - [ ] Full Mastodon API support - [ ] Outbound federation +## Benchmarks + +> **Note**: These benchmarks are not representative of real-world performance, and are only meant to be used as a rough guide. + +### Timeline Benchmarks + +You may run the following command to benchmark the `/api/v1/timelines/home` endpoint: + +```bash +TOKEN=token_here bun benchmark:timeline +``` + +The `request_count` variable is optional and defaults to 100. `TOKEN` is your personal user token, used to login to the API. + +On a quad-core laptop: + +``` +$ bun run benchmarks/timelines.ts 100 +✓ All requests succeeded +✓ 100 requests fulfilled in 0.12611s +``` + +``` +$ bun run benchmarks/timelines.ts 1000 +✓ All requests succeeded +✓ 1000 requests fulfilled in 0.90925s +``` + +``` +$ bun run benchmarks/timelines.ts 10000 +✓ All requests succeeded +✓ 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. + ## How do I run it? ### Requirements @@ -105,6 +143,12 @@ bun cli You can use the `help` command to see a list of available commands. These include creating users, deleting users and more. +#### 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. + +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. + ### 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`. diff --git a/benchmarks/timelines.ts b/benchmarks/timelines.ts new file mode 100644 index 00000000..2804f691 --- /dev/null +++ b/benchmarks/timelines.ts @@ -0,0 +1,56 @@ +/** + * Usage: TOKEN=your_token_here bun benchmark:timeline + */ + +import { getConfig } from "@config"; +import chalk from "chalk"; + +const config = getConfig(); + +const token = process.env.TOKEN; +const requestCount = Number(process.argv[2]) || 100; + +if (!token) { + console.log( + `${chalk.red( + "✗" + )} No token provided. Provide one via the TOKEN environment variable.` + ); + process.exit(1); +} + +const fetchTimeline = () => + fetch(`${config.http.base_url}/api/v1/timelines/home`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).then(res => res.ok); + +const timeNow = performance.now(); + +const requests = Array.from({ length: requestCount }, () => fetchTimeline()); + +Promise.all(requests) + .then(results => { + const timeTaken = performance.now() - timeNow; + if (results.every(t => t)) { + console.log(`${chalk.green("✓")} All requests succeeded`); + } else { + console.log( + `${chalk.red("✗")} ${ + results.filter(t => !t).length + } requests failed` + ); + } + console.log( + `${chalk.green("✓")} ${ + requests.length + } requests fulfilled in ${chalk.bold( + (timeTaken / 1000).toFixed(5) + )}s` + ); + }) + .catch(err => { + console.log(`${chalk.red("✗")} ${err}`); + process.exit(1); + }); diff --git a/bun.lockb b/bun.lockb index 43fc0ec3..ae9680ac 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli.ts b/cli.ts index c9533e57..a42f4d77 100644 --- a/cli.ts +++ b/cli.ts @@ -1,43 +1,92 @@ +import type { Prisma } from "@prisma/client"; import chalk from "chalk"; import { client } from "~database/datasource"; import { createNewLocalUser } from "~database/entities/User"; +import Table from "cli-table"; const args = process.argv; +/** + * Make the text have a width of 20 characters, padding with gray dots + * Text can be a Chalk string, in which case formatting codes should not be counted in text length + * @param text The text to align + */ +const alignDots = (text: string, length = 20) => { + // Remove formatting codes + // eslint-disable-next-line no-control-regex + const textLength = text.replace(/\u001b\[\d+m/g, "").length; + const dots = ".".repeat(length - textLength); + return `${text}${chalk.gray(dots)}`; +}; + +const alignDotsSmall = (text: string, length = 16) => alignDots(text, length); + const help = ` ${chalk.bold(`Usage: bun cli ${chalk.blue("[...flags]")} [...args]`)} ${chalk.bold("Commands:")} - ${chalk.blue("help")} ${chalk.gray( - "................." - )} Show this help message - ${chalk.blue("user")} ${chalk.gray(".................")} Manage users - ${chalk.blue("create")} ${chalk.gray("...........")} Create a new user - ${chalk.green("username")} ${chalk.gray( - "....." - )} Username of the user - ${chalk.green("password")} ${chalk.gray( - "....." - )} Password of the user - ${chalk.green("email")} ${chalk.gray("........")} Email of the user - ${chalk.yellow("--admin")} ${chalk.gray( - "......" + ${alignDots(chalk.blue("help"), 24)} Show this help message + ${alignDots(chalk.blue("user"), 24)} Manage users + ${alignDots(chalk.blue("create"))} Create a new user + ${alignDotsSmall(chalk.green("username"))} Username of the user + ${alignDotsSmall(chalk.green("password"))} Password of the user + ${alignDotsSmall(chalk.green("email"))} Email of the user + ${alignDotsSmall( + chalk.yellow("--admin") )} Make the user an admin (optional) ${chalk.bold("Example:")} ${chalk.bgGray( `bun cli user create admin password123 admin@gmail.com --admin` )} - ${chalk.blue("delete")} ${chalk.gray("...........")} Delete a user - ${chalk.green("username")} ${chalk.gray( - "....." - )} Username of the user + ${alignDots(chalk.blue("delete"))} Delete a user + ${alignDotsSmall(chalk.green("username"))} Username of the user ${chalk.bold("Example:")} ${chalk.bgGray( `bun cli user delete admin` )} - ${chalk.blue("list")} ${chalk.gray(".............")} List all users - ${chalk.yellow("--admins")} ${chalk.gray( - "....." + ${alignDots(chalk.blue("list"))} List all users + ${alignDotsSmall( + chalk.yellow("--admins") )} List only admins (optional) ${chalk.bold("Example:")} ${chalk.bgGray(`bun cli user list`)} + ${alignDots(chalk.blue("search"))} Search for a user + ${alignDotsSmall(chalk.green("query"))} Query to search for + ${alignDotsSmall( + chalk.yellow("--displayname") + )} Search by display name (optional) + ${alignDotsSmall(chalk.yellow("--bio"))} Search in bio (optional) + ${alignDotsSmall( + chalk.yellow("--local") + )} Search in local users (optional) + ${alignDotsSmall( + chalk.yellow("--remote") + )} Search in remote users (optional) + ${alignDotsSmall( + chalk.yellow("--email") + )} Search in emails (optional) + ${alignDotsSmall(chalk.yellow("--json"))} Output as JSON (optional) + ${alignDotsSmall(chalk.yellow("--csv"))} Output as CSV (optional) + ${chalk.bold("Example:")} ${chalk.bgGray( + `bun cli user search admin` + )} + ${alignDots(chalk.blue("note"), 24)} Manage notes + ${alignDots(chalk.blue("delete"))} Delete a note + ${alignDotsSmall(chalk.green("id"))} ID of the note + ${chalk.bold("Example:")} ${chalk.bgGray( + `bun cli note delete 018c1838-6e0b-73c4-a157-a91ea4e25d1d` + )} + ${alignDots(chalk.blue("search"))} Search for a status + ${alignDotsSmall(chalk.green("query"))} Query to search for + ${alignDotsSmall( + chalk.yellow("--local") + )} Search in local statuses (optional) + ${alignDotsSmall( + chalk.yellow("--remote") + )} Search in remote statuses (optional) + ${alignDotsSmall(chalk.yellow("--json"))} Output as JSON (optional) + ${alignDotsSmall(chalk.yellow("--csv"))} Output as CSV (optional) + ${chalk.bold("Example:")} ${chalk.bgGray( + `bun cli note search hello` + )} + `; if (args.length < 3) { @@ -141,6 +190,7 @@ switch (command) { where: { isAdmin: admins || undefined, }, + take: 200, }); console.log( @@ -158,11 +208,305 @@ switch (command) { } break; } + case "search": { + const argsWithoutFlags = args.filter( + arg => !arg.startsWith("--") + ); + const query = argsWithoutFlags[4]; + + if (!query) { + console.log(`${chalk.red(`✗`)} Missing query`); + process.exit(1); + } + + const displayname = args.includes("--displayname"); + const bio = args.includes("--bio"); + const local = args.includes("--local"); + const remote = args.includes("--remote"); + const email = args.includes("--email"); + const json = args.includes("--json"); + const csv = args.includes("--csv"); + + const queries: Prisma.UserWhereInput[] = []; + + if (displayname) { + queries.push({ + displayName: { + contains: query, + mode: "insensitive", + }, + }); + } + + if (bio) { + queries.push({ + note: { + contains: query, + mode: "insensitive", + }, + }); + } + + if (local) { + queries.push({ + instanceId: null, + }); + } + + if (remote) { + queries.push({ + instanceId: { + not: null, + }, + }); + } + + if (email) { + queries.push({ + email: { + contains: query, + mode: "insensitive", + }, + }); + } + + const users = await client.user.findMany({ + where: { + AND: queries, + }, + include: { + instance: true, + }, + take: 40, + }); + + if (json || csv) { + if (json) { + console.log(JSON.stringify(users, null, 4)); + } + if (csv) { + // Convert the outputted JSON to CSV + + // Remove all object children from each object + const items = users.map(user => { + const item = { + ...user, + instance: undefined, + endpoints: undefined, + source: undefined, + }; + return item; + }); + const replacer = (key: string, value: any): any => + value === null ? "" : value; // Null values are returned as empty strings + const header = Object.keys(items[0]); + const csv = [ + header.join(","), // header row first + ...items.map(row => + header + .map(fieldName => + // @ts-expect-error This is fine + JSON.stringify(row[fieldName], replacer) + ) + .join(",") + ), + ].join("\r\n"); + + console.log(csv); + } + } else { + console.log( + `${chalk.green(`✓`)} Found ${chalk.blue( + users.length + )} users` + ); + + const table = new Table({ + head: [ + chalk.white(chalk.bold("Username")), + chalk.white(chalk.bold("Email")), + chalk.white(chalk.bold("Display Name")), + chalk.white(chalk.bold("Admin?")), + chalk.white(chalk.bold("Instance URL")), + ], + }); + + for (const user of users) { + table.push([ + chalk.yellow(`@${user.username}`), + chalk.green(user.email), + chalk.blue(user.displayName), + chalk.red(user.isAdmin ? "Yes" : "No"), + chalk.blue( + user.instanceId + ? user.instance?.base_url + : "Local" + ), + ]); + } + + console.log(table.toString()); + } + + break; + } default: console.log(`Unknown command ${chalk.blue(command)}`); break; } break; + case "note": { + switch (args[3]) { + case "delete": { + const id = args[4]; + + if (!id) { + console.log(`${chalk.red(`✗`)} Missing ID`); + process.exit(1); + } + + const note = await client.status.findFirst({ + where: { + id: id, + }, + }); + + if (!note) { + console.log(`${chalk.red(`✗`)} Note not found`); + process.exit(1); + } + + await client.status.delete({ + where: { + id: note.id, + }, + }); + + console.log( + `${chalk.green(`✓`)} Deleted note ${chalk.blue(note.id)}` + ); + + break; + } + case "search": { + const argsWithoutFlags = args.filter( + arg => !arg.startsWith("--") + ); + const query = argsWithoutFlags[4]; + + if (!query) { + console.log(`${chalk.red(`✗`)} Missing query`); + process.exit(1); + } + + const local = args.includes("--local"); + const remote = args.includes("--remote"); + const json = args.includes("--json"); + const csv = args.includes("--csv"); + + const queries: Prisma.StatusWhereInput[] = []; + + if (local) { + queries.push({ + instanceId: null, + }); + } + + if (remote) { + queries.push({ + instanceId: { + not: null, + }, + }); + } + + const statuses = await client.status.findMany({ + where: { + AND: queries, + content: { + contains: query, + mode: "insensitive", + }, + }, + take: 40, + include: { + author: true, + instance: true, + }, + }); + + if (json || csv) { + if (json) { + console.log(JSON.stringify(statuses, null, 4)); + } + if (csv) { + // Convert the outputted JSON to CSV + + // Remove all object children from each object + const items = statuses.map(status => { + const item = { + ...status, + author: undefined, + instance: undefined, + }; + return item; + }); + const replacer = (key: string, value: any): any => + value === null ? "" : value; // Null values are returned as empty strings + const header = Object.keys(items[0]); + const csv = [ + header.join(","), // header row first + ...items.map(row => + header + .map(fieldName => + // @ts-expect-error This is fine + JSON.stringify(row[fieldName], replacer) + ) + .join(",") + ), + ].join("\r\n"); + + console.log(csv); + } + } else { + console.log( + `${chalk.green(`✓`)} Found ${chalk.blue( + statuses.length + )} statuses` + ); + + const table = new Table({ + head: [ + chalk.white(chalk.bold("Username")), + chalk.white(chalk.bold("Instance URL")), + chalk.white(chalk.bold("Content")), + ], + }); + + for (const status of statuses) { + table.push([ + chalk.yellow(`@${status.author.username}`), + chalk.blue( + status.instanceId + ? status.instance?.base_url + : "Local" + ), + chalk.green(status.content.slice(0, 50)), + ]); + } + + console.log(table.toString()); + } + + break; + } + default: + console.log(`Unknown command ${chalk.blue(command)}`); + break; + } + + break; + } default: console.log(`Unknown command ${chalk.blue(command)}`); break; diff --git a/config/config.example.toml b/config/config.example.toml index 8c8e92bd..603eb05a 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -7,9 +7,16 @@ database = "lysand" [redis.queue] host = "localhost" -post = 6379 +port = 6379 password = "" -# database = 0 +database = 0 + +[redis.cache] +host = "localhost" +port = 6379 +password = "" +database = 1 +enabled = false [http] base_url = "https://lysand.social" diff --git a/database/entities/User.ts b/database/entities/User.ts index 462292ac..6c781fb6 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -376,7 +376,10 @@ export const userToAPI = ( discoverable: undefined, mute_expires_at: undefined, group: false, - role: undefined, + pleroma: { + is_admin: user.isAdmin, + is_moderator: user.isAdmin, + }, }; }; diff --git a/docker-compose.yml b/docker-compose.yml index f8f28d75..e8100507 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: #- ./logs:/app/logs - ./config:/app/config - ./.env:/app/.env + - ./uploads:/app/uploads restart: unless-stopped container_name: lysand networks: diff --git a/index.ts b/index.ts index b812efc9..38a9de04 100644 --- a/index.ts +++ b/index.ts @@ -11,7 +11,9 @@ 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"; +const timeAtStart = performance.now(); const server = new Server(); const router = new Bun.FileSystemRouter({ @@ -32,10 +34,16 @@ if (!(await requests_log.exists())) { await Bun.write(process.cwd() + "/logs/requests.log", ""); } +const redisCache = await initializeRedisCache(); + +if (redisCache) { + client.$use(redisCache); +} + // Check if database is reachable -const postCount = 0; +let postCount = 0; try { - await client.status.count(); + postCount = await client.status.count(); } catch (e) { const error = e as PrismaClientInitializationError; console.error( @@ -171,7 +179,7 @@ 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` )}` ); diff --git a/package.json b/package.json index a8cbc3f1..224545b0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "lysand", "module": "index.ts", "type": "module", - "version": "0.0.1", + "version": "0.1.2", "description": "A project to build a federated social network", "author": { "email": "contact@cpluspatch.com", @@ -39,6 +39,7 @@ "lint": "eslint --config .eslintrc.cjs --ext .ts .", "prisma": "bun run prisma.ts", "generate": "bun prisma generate", + "benchmark:timeline": "bun run benchmarks/timelines.ts", "cli": "bun run cli.ts" }, "trustedDependencies": [ @@ -48,7 +49,9 @@ "devDependencies": { "@julr/unocss-preset-forms": "^0.1.0", "@microsoft/eslint-formatter-sarif": "^3.0.0", + "@types/cli-table": "^0.3.4", "@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", @@ -73,14 +76,17 @@ "blurhash": "^2.0.5", "bullmq": "^4.14.4", "chalk": "^5.3.0", + "cli-table": "^0.3.11", "eventemitter3": "^5.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", "jsonld": "^8.3.1", "marked": "^9.1.2", "prisma": "^5.6.0", + "prisma-redis-middleware": "^4.8.0", "semver": "^7.5.4", "sharp": "^0.33.0-rc.2" } diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts index 49964db5..94543895 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -23,13 +23,5 @@ export default async (req: Request): Promise => { return jsonResponse({ ...userToAPI(user, true), - // TODO: Add role support - role: { - id: 0, - name: "", - permissions: "", - color: "", - highlighted: false, - }, }); }; diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index 1904aa31..d9a87537 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -2,6 +2,9 @@ import { applyConfig } from "@api"; import { getConfig } from "@config"; import { jsonResponse } from "@response"; import { client } from "~database/datasource"; +import { userRelations, userToAPI } from "~database/entities/User"; +import type { APIInstance } from "~types/entities/instance"; +import manifest from "~package.json"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -22,6 +25,9 @@ export const meta = applyConfig({ export default async (): Promise => { const config = getConfig(); + // Get software version from package.json + const version = manifest.version; + const statusCount = await client.status.count({ where: { instanceId: null, @@ -33,12 +39,40 @@ export default async (): Promise => { }, }); + // Get the first created admin user + const contactAccount = await client.user.findFirst({ + where: { + instanceId: null, + isAdmin: true, + }, + orderBy: { + id: "asc", + }, + include: userRelations, + }); + + // Get user that have posted once in the last 30 days + const monthlyActiveUsers = await client.user.count({ + where: { + instanceId: null, + statuses: { + some: { + createdAt: { + gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }, + }, + }, + }, + }); + + const knownDomainsCount = await client.instance.count(); + // TODO: fill in more values return jsonResponse({ approval_required: false, configuration: { media_attachments: { - image_matrix_limit: 10, + image_matrix_limit: config.validation.max_media_attachments, image_size_limit: config.validation.max_media_size, supported_mime_types: config.validation.allowed_mime_types, video_frame_limit: 60, @@ -46,9 +80,10 @@ export default async (): Promise => { video_size_limit: config.validation.max_media_size, }, polls: { - max_characters_per_option: 100, - max_expiration: 60 * 60 * 24 * 365 * 100, // 100 years, - max_options: 40, + max_characters_per_option: + config.validation.max_poll_option_size, + max_expiration: config.validation.max_poll_duration, + max_options: config.validation.max_poll_options, min_expiration: 60, }, statuses: { @@ -70,7 +105,7 @@ export default async (): Promise => { languages: ["en"], rules: [], stats: { - domain_count: 1, + domain_count: knownDomainsCount, status_count: statusCount, user_count: userCount, }, @@ -80,7 +115,7 @@ export default async (): Promise => { urls: { streaming_api: "", }, - version: "4.2.0+glitch (compatible; Lysand 0.0.1)", + version: `4.2.0+glitch (compatible; Lysand ${version}})`, max_toot_chars: config.validation.max_note_size, pleroma: { metadata: { @@ -115,8 +150,9 @@ export default async (): Promise => { privileged_staff: false, }, stats: { - mau: 2, + mau: monthlyActiveUsers, }, }, - }); + contact_account: contactAccount ? userToAPI(contactAccount) : null, + } as APIInstance); }; diff --git a/server/api/media/[id]/index.ts b/server/api/media/[id]/index.ts new file mode 100644 index 00000000..cd8ebfae --- /dev/null +++ b/server/api/media/[id]/index.ts @@ -0,0 +1,49 @@ +import { errorResponse } from "@response"; +import { applyConfig } from "@api"; +import type { MatchedRoute } from "bun"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/media/:id", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: false, + }, +}); + +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + // TODO: Add checks for disabled or not email verified accounts + + const id = matchedRoute.params.id; + + // parse `Range` header + const [start = 0, end = Infinity] = ( + (req.headers.get("Range") || "") + .split("=") // ["Range: bytes", "0-100"] + .at(-1) || "" + ) // "0-100" + .split("-") // ["0", "100"] + .map(Number); // [0, 100] + + // Serve file from filesystem + const file = Bun.file(`./uploads/${id}`); + + const buffer = await file.arrayBuffer(); + + if (!(await file.exists())) return errorResponse("File not found", 404); + + // Can't directly copy file into Response because this crashes Bun for now + return new Response(buffer, { + headers: { + "Content-Type": file.type || "application/octet-stream", + "Content-Length": `${file.size - start}`, + "Content-Range": `bytes ${start}-${end}/${file.size}`, + }, + }); +}; diff --git a/types/entities/account.ts b/types/entities/account.ts index 6348dc09..0d418927 100644 --- a/types/entities/account.ts +++ b/types/entities/account.ts @@ -31,4 +31,5 @@ export interface APIAccount { source?: APISource; role?: APIRole; mute_expires_at?: string; + pleroma?: any; } diff --git a/utils/config.ts b/utils/config.ts index eddec0b6..92553c16 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -16,6 +16,13 @@ export interface ConfigType { password: string; database: number | null; }; + cache: { + host: string; + port: number; + password: string; + database: number | null; + enabled: boolean; + }; }; http: { @@ -159,7 +166,14 @@ export const configDefaults: ConfigType = { host: "localhost", port: 6379, password: "", - database: null, + database: 0, + }, + cache: { + host: "localhost", + port: 6379, + password: "", + database: 1, + enabled: false, }, }, instance: { diff --git a/utils/redis.ts b/utils/redis.ts new file mode 100644 index 00000000..a5715bc1 --- /dev/null +++ b/utils/redis.ts @@ -0,0 +1,60 @@ +import { getConfig } from "@config"; +import type { Prisma } from "@prisma/client"; +import chalk from "chalk"; +import Redis from "ioredis"; +import { createPrismaRedisCache } from "prisma-redis-middleware"; + +const config = getConfig(); + +const cacheRedis = config.redis.cache.enabled + ? new Redis({ + host: config.redis.cache.host, + port: Number(config.redis.cache.port), + password: config.redis.cache.password, + db: Number(config.redis.cache.database ?? 0), + }) + : null; + +cacheRedis?.on("error", e => { + console.log(e); +}); + +export { cacheRedis }; + +export const initializeRedisCache = async () => { + if (cacheRedis) { + // Test connection + try { + await cacheRedis.ping(); + } catch (e) { + console.error( + `${chalk.red(`✗`)} ${chalk.bold( + `Error while connecting to Redis` + )}` + ); + throw e; + } + + console.log(`${chalk.green(`✓`)} ${chalk.bold(`Connected to Redis`)}`); + + const cacheMiddleware: Prisma.Middleware = createPrismaRedisCache({ + storage: { + type: "redis", + options: { + client: cacheRedis, + invalidation: { + referencesTTL: 300, + }, + }, + }, + cacheTime: 300, + onError: e => { + console.error(e); + }, + }); + + return cacheMiddleware; + } + + return null; +};