mirror of
https://github.com/versia-pub/server.git
synced 2026-01-26 12:16:01 +01:00
commit
2fb3e5c529
|
|
@ -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)
|
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
|
```bash
|
||||||
bun migrate
|
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.
|
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
|
### Writing documentation
|
||||||
|
|
||||||
|
|
|
||||||
46
README.md
46
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 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.
|
> **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
|
## Features
|
||||||
|
|
||||||
- [x] Inbound federation
|
- [x] Inbound federation
|
||||||
|
- [x] Hyper fast (thousands of HTTP requests per second)
|
||||||
- [x] S3 or local media storage
|
- [x] S3 or local media storage
|
||||||
- [x] Deduplication of uploaded files
|
- [x] Deduplication of uploaded files
|
||||||
- [x] Federation limits
|
- [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] Full regex-based filters for posts, users and media
|
||||||
- [x] Custom emoji support
|
- [x] Custom emoji support
|
||||||
- [x] Automatic image conversion to WebP or other formats
|
- [x] Automatic image conversion to WebP or other formats
|
||||||
|
- [x] Scripting-compatible CLI with JSON and CSV outputs
|
||||||
- [ ] Moderation tools
|
- [ ] Moderation tools
|
||||||
- [ ] Full Mastodon API support
|
- [ ] Full Mastodon API support
|
||||||
- [ ] Outbound federation
|
- [ ] 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 <request_count>
|
||||||
|
```
|
||||||
|
|
||||||
|
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?
|
## How do I run it?
|
||||||
|
|
||||||
### Requirements
|
### 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.
|
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
|
### 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`.
|
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`.
|
||||||
|
|
|
||||||
56
benchmarks/timelines.ts
Normal file
56
benchmarks/timelines.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
/**
|
||||||
|
* Usage: TOKEN=your_token_here bun benchmark:timeline <request_count>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
386
cli.ts
386
cli.ts
|
|
@ -1,43 +1,92 @@
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import { createNewLocalUser } from "~database/entities/User";
|
import { createNewLocalUser } from "~database/entities/User";
|
||||||
|
import Table from "cli-table";
|
||||||
|
|
||||||
const args = process.argv;
|
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 = `
|
const help = `
|
||||||
${chalk.bold(`Usage: bun cli <command> ${chalk.blue("[...flags]")} [...args]`)}
|
${chalk.bold(`Usage: bun cli <command> ${chalk.blue("[...flags]")} [...args]`)}
|
||||||
|
|
||||||
${chalk.bold("Commands:")}
|
${chalk.bold("Commands:")}
|
||||||
${chalk.blue("help")} ${chalk.gray(
|
${alignDots(chalk.blue("help"), 24)} Show this help message
|
||||||
"................."
|
${alignDots(chalk.blue("user"), 24)} Manage users
|
||||||
)} Show this help message
|
${alignDots(chalk.blue("create"))} Create a new user
|
||||||
${chalk.blue("user")} ${chalk.gray(".................")} Manage users
|
${alignDotsSmall(chalk.green("username"))} Username of the user
|
||||||
${chalk.blue("create")} ${chalk.gray("...........")} Create a new user
|
${alignDotsSmall(chalk.green("password"))} Password of the user
|
||||||
${chalk.green("username")} ${chalk.gray(
|
${alignDotsSmall(chalk.green("email"))} Email of the user
|
||||||
"....."
|
${alignDotsSmall(
|
||||||
)} Username of the user
|
chalk.yellow("--admin")
|
||||||
${chalk.green("password")} ${chalk.gray(
|
|
||||||
"....."
|
|
||||||
)} Password of the user
|
|
||||||
${chalk.green("email")} ${chalk.gray("........")} Email of the user
|
|
||||||
${chalk.yellow("--admin")} ${chalk.gray(
|
|
||||||
"......"
|
|
||||||
)} Make the user an admin (optional)
|
)} Make the user an admin (optional)
|
||||||
${chalk.bold("Example:")} ${chalk.bgGray(
|
${chalk.bold("Example:")} ${chalk.bgGray(
|
||||||
`bun cli user create admin password123 admin@gmail.com --admin`
|
`bun cli user create admin password123 admin@gmail.com --admin`
|
||||||
)}
|
)}
|
||||||
${chalk.blue("delete")} ${chalk.gray("...........")} Delete a user
|
${alignDots(chalk.blue("delete"))} Delete a user
|
||||||
${chalk.green("username")} ${chalk.gray(
|
${alignDotsSmall(chalk.green("username"))} Username of the user
|
||||||
"....."
|
|
||||||
)} Username of the user
|
|
||||||
${chalk.bold("Example:")} ${chalk.bgGray(
|
${chalk.bold("Example:")} ${chalk.bgGray(
|
||||||
`bun cli user delete admin`
|
`bun cli user delete admin`
|
||||||
)}
|
)}
|
||||||
${chalk.blue("list")} ${chalk.gray(".............")} List all users
|
${alignDots(chalk.blue("list"))} List all users
|
||||||
${chalk.yellow("--admins")} ${chalk.gray(
|
${alignDotsSmall(
|
||||||
"....."
|
chalk.yellow("--admins")
|
||||||
)} List only admins (optional)
|
)} List only admins (optional)
|
||||||
${chalk.bold("Example:")} ${chalk.bgGray(`bun cli user list`)}
|
${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) {
|
if (args.length < 3) {
|
||||||
|
|
@ -141,6 +190,7 @@ switch (command) {
|
||||||
where: {
|
where: {
|
||||||
isAdmin: admins || undefined,
|
isAdmin: admins || undefined,
|
||||||
},
|
},
|
||||||
|
take: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -158,11 +208,305 @@ switch (command) {
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
console.log(`Unknown command ${chalk.blue(command)}`);
|
console.log(`Unknown command ${chalk.blue(command)}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
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:
|
default:
|
||||||
console.log(`Unknown command ${chalk.blue(command)}`);
|
console.log(`Unknown command ${chalk.blue(command)}`);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,16 @@ database = "lysand"
|
||||||
|
|
||||||
[redis.queue]
|
[redis.queue]
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
post = 6379
|
port = 6379
|
||||||
password = ""
|
password = ""
|
||||||
# database = 0
|
database = 0
|
||||||
|
|
||||||
|
[redis.cache]
|
||||||
|
host = "localhost"
|
||||||
|
port = 6379
|
||||||
|
password = ""
|
||||||
|
database = 1
|
||||||
|
enabled = false
|
||||||
|
|
||||||
[http]
|
[http]
|
||||||
base_url = "https://lysand.social"
|
base_url = "https://lysand.social"
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,10 @@ export const userToAPI = (
|
||||||
discoverable: undefined,
|
discoverable: undefined,
|
||||||
mute_expires_at: undefined,
|
mute_expires_at: undefined,
|
||||||
group: false,
|
group: false,
|
||||||
role: undefined,
|
pleroma: {
|
||||||
|
is_admin: user.isAdmin,
|
||||||
|
is_moderator: user.isAdmin,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ services:
|
||||||
#- ./logs:/app/logs
|
#- ./logs:/app/logs
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
- ./.env:/app/.env
|
- ./.env:/app/.env
|
||||||
|
- ./uploads:/app/uploads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
container_name: lysand
|
container_name: lysand
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
14
index.ts
14
index.ts
|
|
@ -11,7 +11,9 @@ import { mkdir } from "fs/promises";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import type { PrismaClientInitializationError } from "@prisma/client/runtime/library";
|
import type { PrismaClientInitializationError } from "@prisma/client/runtime/library";
|
||||||
import { HookTypes, Server } from "~plugins/types";
|
import { HookTypes, Server } from "~plugins/types";
|
||||||
|
import { initializeRedisCache } from "@redis";
|
||||||
|
|
||||||
|
const timeAtStart = performance.now();
|
||||||
const server = new Server();
|
const server = new Server();
|
||||||
|
|
||||||
const router = new Bun.FileSystemRouter({
|
const router = new Bun.FileSystemRouter({
|
||||||
|
|
@ -32,10 +34,16 @@ if (!(await requests_log.exists())) {
|
||||||
await Bun.write(process.cwd() + "/logs/requests.log", "");
|
await Bun.write(process.cwd() + "/logs/requests.log", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const redisCache = await initializeRedisCache();
|
||||||
|
|
||||||
|
if (redisCache) {
|
||||||
|
client.$use(redisCache);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if database is reachable
|
// Check if database is reachable
|
||||||
const postCount = 0;
|
let postCount = 0;
|
||||||
try {
|
try {
|
||||||
await client.status.count();
|
postCount = await client.status.count();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as PrismaClientInitializationError;
|
const error = e as PrismaClientInitializationError;
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -171,7 +179,7 @@ console.log(
|
||||||
`${chalk.green(`✓`)} ${chalk.bold(
|
`${chalk.green(`✓`)} ${chalk.bold(
|
||||||
`Lysand started at ${chalk.blue(
|
`Lysand started at ${chalk.blue(
|
||||||
`${config.http.bind}:${config.http.bind_port}`
|
`${config.http.bind}:${config.http.bind_port}`
|
||||||
)}`
|
)} in ${chalk.gray((performance.now() - timeAtStart).toFixed(0))}ms`
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "lysand",
|
"name": "lysand",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.1.2",
|
||||||
"description": "A project to build a federated social network",
|
"description": "A project to build a federated social network",
|
||||||
"author": {
|
"author": {
|
||||||
"email": "contact@cpluspatch.com",
|
"email": "contact@cpluspatch.com",
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
"lint": "eslint --config .eslintrc.cjs --ext .ts .",
|
"lint": "eslint --config .eslintrc.cjs --ext .ts .",
|
||||||
"prisma": "bun run prisma.ts",
|
"prisma": "bun run prisma.ts",
|
||||||
"generate": "bun prisma generate",
|
"generate": "bun prisma generate",
|
||||||
|
"benchmark:timeline": "bun run benchmarks/timelines.ts",
|
||||||
"cli": "bun run cli.ts"
|
"cli": "bun run cli.ts"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
|
|
@ -48,7 +49,9 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@julr/unocss-preset-forms": "^0.1.0",
|
"@julr/unocss-preset-forms": "^0.1.0",
|
||||||
"@microsoft/eslint-formatter-sarif": "^3.0.0",
|
"@microsoft/eslint-formatter-sarif": "^3.0.0",
|
||||||
|
"@types/cli-table": "^0.3.4",
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/html-to-text": "^9.0.4",
|
||||||
|
"@types/ioredis": "^5.0.0",
|
||||||
"@types/jsonld": "^1.5.13",
|
"@types/jsonld": "^1.5.13",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||||
"@typescript-eslint/parser": "^6.13.1",
|
"@typescript-eslint/parser": "^6.13.1",
|
||||||
|
|
@ -73,14 +76,17 @@
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"bullmq": "^4.14.4",
|
"bullmq": "^4.14.4",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
|
"cli-table": "^0.3.11",
|
||||||
"eventemitter3": "^5.0.1",
|
"eventemitter3": "^5.0.1",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
"ip-matching": "^2.1.2",
|
"ip-matching": "^2.1.2",
|
||||||
"iso-639-1": "^3.1.0",
|
"iso-639-1": "^3.1.0",
|
||||||
"isomorphic-dompurify": "^1.10.0",
|
"isomorphic-dompurify": "^1.10.0",
|
||||||
"jsonld": "^8.3.1",
|
"jsonld": "^8.3.1",
|
||||||
"marked": "^9.1.2",
|
"marked": "^9.1.2",
|
||||||
"prisma": "^5.6.0",
|
"prisma": "^5.6.0",
|
||||||
|
"prisma-redis-middleware": "^4.8.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"sharp": "^0.33.0-rc.2"
|
"sharp": "^0.33.0-rc.2"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,5 @@ export default async (req: Request): Promise<Response> => {
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
...userToAPI(user, true),
|
...userToAPI(user, true),
|
||||||
// TODO: Add role support
|
|
||||||
role: {
|
|
||||||
id: 0,
|
|
||||||
name: "",
|
|
||||||
permissions: "",
|
|
||||||
color: "",
|
|
||||||
highlighted: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { applyConfig } from "@api";
|
||||||
import { getConfig } from "@config";
|
import { getConfig } from "@config";
|
||||||
import { jsonResponse } from "@response";
|
import { jsonResponse } from "@response";
|
||||||
import { client } from "~database/datasource";
|
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({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -22,6 +25,9 @@ export const meta = applyConfig({
|
||||||
export default async (): Promise<Response> => {
|
export default async (): Promise<Response> => {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
|
// Get software version from package.json
|
||||||
|
const version = manifest.version;
|
||||||
|
|
||||||
const statusCount = await client.status.count({
|
const statusCount = await client.status.count({
|
||||||
where: {
|
where: {
|
||||||
instanceId: null,
|
instanceId: null,
|
||||||
|
|
@ -33,12 +39,40 @@ export default async (): Promise<Response> => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// TODO: fill in more values
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
approval_required: false,
|
approval_required: false,
|
||||||
configuration: {
|
configuration: {
|
||||||
media_attachments: {
|
media_attachments: {
|
||||||
image_matrix_limit: 10,
|
image_matrix_limit: config.validation.max_media_attachments,
|
||||||
image_size_limit: config.validation.max_media_size,
|
image_size_limit: config.validation.max_media_size,
|
||||||
supported_mime_types: config.validation.allowed_mime_types,
|
supported_mime_types: config.validation.allowed_mime_types,
|
||||||
video_frame_limit: 60,
|
video_frame_limit: 60,
|
||||||
|
|
@ -46,9 +80,10 @@ export default async (): Promise<Response> => {
|
||||||
video_size_limit: config.validation.max_media_size,
|
video_size_limit: config.validation.max_media_size,
|
||||||
},
|
},
|
||||||
polls: {
|
polls: {
|
||||||
max_characters_per_option: 100,
|
max_characters_per_option:
|
||||||
max_expiration: 60 * 60 * 24 * 365 * 100, // 100 years,
|
config.validation.max_poll_option_size,
|
||||||
max_options: 40,
|
max_expiration: config.validation.max_poll_duration,
|
||||||
|
max_options: config.validation.max_poll_options,
|
||||||
min_expiration: 60,
|
min_expiration: 60,
|
||||||
},
|
},
|
||||||
statuses: {
|
statuses: {
|
||||||
|
|
@ -70,7 +105,7 @@ export default async (): Promise<Response> => {
|
||||||
languages: ["en"],
|
languages: ["en"],
|
||||||
rules: [],
|
rules: [],
|
||||||
stats: {
|
stats: {
|
||||||
domain_count: 1,
|
domain_count: knownDomainsCount,
|
||||||
status_count: statusCount,
|
status_count: statusCount,
|
||||||
user_count: userCount,
|
user_count: userCount,
|
||||||
},
|
},
|
||||||
|
|
@ -80,7 +115,7 @@ export default async (): Promise<Response> => {
|
||||||
urls: {
|
urls: {
|
||||||
streaming_api: "",
|
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,
|
max_toot_chars: config.validation.max_note_size,
|
||||||
pleroma: {
|
pleroma: {
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|
@ -115,8 +150,9 @@ export default async (): Promise<Response> => {
|
||||||
privileged_staff: false,
|
privileged_staff: false,
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
mau: 2,
|
mau: monthlyActiveUsers,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
contact_account: contactAccount ? userToAPI(contactAccount) : null,
|
||||||
|
} as APIInstance);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
49
server/api/media/[id]/index.ts
Normal file
49
server/api/media/[id]/index.ts
Normal file
|
|
@ -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<Response> => {
|
||||||
|
// 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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -31,4 +31,5 @@ export interface APIAccount {
|
||||||
source?: APISource;
|
source?: APISource;
|
||||||
role?: APIRole;
|
role?: APIRole;
|
||||||
mute_expires_at?: string;
|
mute_expires_at?: string;
|
||||||
|
pleroma?: any;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ export interface ConfigType {
|
||||||
password: string;
|
password: string;
|
||||||
database: number | null;
|
database: number | null;
|
||||||
};
|
};
|
||||||
|
cache: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
password: string;
|
||||||
|
database: number | null;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
http: {
|
http: {
|
||||||
|
|
@ -159,7 +166,14 @@ export const configDefaults: ConfigType = {
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 6379,
|
port: 6379,
|
||||||
password: "",
|
password: "",
|
||||||
database: null,
|
database: 0,
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
host: "localhost",
|
||||||
|
port: 6379,
|
||||||
|
password: "",
|
||||||
|
database: 1,
|
||||||
|
enabled: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
instance: {
|
instance: {
|
||||||
|
|
|
||||||
60
utils/redis.ts
Normal file
60
utils/redis.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue