feat(api): Add TOS and Privacy Policy support

This commit is contained in:
Jesse Wierzbinski 2024-06-11 09:55:40 -10:00
parent e9e33432c2
commit ffcf01e3cd
No known key found for this signature in database
12 changed files with 180 additions and 33 deletions

View file

@ -306,7 +306,13 @@ name = "Lysand"
description = "A Lysand instance" description = "A Lysand instance"
# Path to a file containing a longer description of your instance # Path to a file containing a longer description of your instance
# This will be parsed as Markdown # This will be parsed as Markdown
# extended_description_path = "config/description.md" # extended_description_path = "config/extended_description.md"
# Path to a file containing the terms of service of your instance
# This will be parsed as Markdown
# tos_path = "config/tos.md"
# Path to a file containing the privacy policy of your instance
# This will be parsed as Markdown
# privacy_policy_path = "config/privacy_policy.md"
# URL to your instance logo # URL to your instance logo
# logo = "" # logo = ""
# URL to your instance banner # URL to your instance banner

View file

@ -26,3 +26,7 @@ For frontend developers. Please read [the documentation](./frontend.md).
## Mastodon API Extensions ## Mastodon API Extensions
Extra attributes have been added to some Mastodon API routes. Those changes are [documented in this document](./mastodon.md) Extra attributes have been added to some Mastodon API routes. Those changes are [documented in this document](./mastodon.md)
## Instance API
Extra endpoints have been added to the API to provide additional information about the instance. Please read [the documentation](./instance.md).

11
docs/api/instance.md Normal file
View file

@ -0,0 +1,11 @@
# Instance Endpoints
Extra endpoints have been added to the API to provide additional information about the instance.
## `/api/v1/instance/tos`
Returns the same output as Mastodon's `/api/v1/instance/extended_description`, but with the instance's Terms of Service. Configurable at `instance.tos_path` in config.
## `/api/v1/instance/privacy_policy`
Returns the same output as Mastodon's `/api/v1/instance/extended_description`, but with the instance's Privacy Policy. Configurable at `instance.privacy_policy_path` in config.

View file

@ -30,7 +30,7 @@ The URL of the instance's banner image. `null` if there is no banner set.
The version of the Lysand instance. The version of the Lysand instance.
The normal `version` field is always set to `"4.3.0+glitch"` or similar to not confuse clients that expect a Mastodon instance. The normal `version` field is always set to `"4.3.0+glitch"` or similar, to not confuse clients that expect a Mastodon instance.
### `sso` ### `sso`

View file

@ -88,7 +88,6 @@ export const configValidator = z.object({
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
}), }),
signups: z.object({ signups: z.object({
tos_url: z.string().min(1).optional(),
registration: z.boolean().default(true), registration: z.boolean().default(true),
rules: z.array(z.string()).default([]), rules: z.array(z.string()).default([]),
}), }),
@ -475,6 +474,8 @@ export const configValidator = z.object({
name: z.string().min(1).default("Lysand"), name: z.string().min(1).default("Lysand"),
description: z.string().min(1).default("A Lysand instance"), description: z.string().min(1).default("A Lysand instance"),
extended_description_path: z.string().optional(), extended_description_path: z.string().optional(),
tos_path: z.string().optional(),
privacy_policy_path: z.string().optional(),
logo: zUrl.optional(), logo: zUrl.optional(),
banner: zUrl.optional(), banner: zUrl.optional(),
}) })
@ -482,6 +483,8 @@ export const configValidator = z.object({
name: "Lysand", name: "Lysand",
description: "A Lysand instance", description: "A Lysand instance",
extended_description_path: undefined, extended_description_path: undefined,
tos_path: undefined,
privacy_policy_path: undefined,
logo: undefined, logo: undefined,
banner: undefined, banner: undefined,
}), }),

View file

@ -14,7 +14,7 @@ describe(meta.route, () => {
const json = await response.json(); const json = await response.json();
expect(json).toEqual({ expect(json).toEqual({
updated_at: new Date(2024, 0, 0).toISOString(), updated_at: new Date(1970, 0, 0).toISOString(),
// This is a [Lysand](https://lysand.org) server with the default extended description. // This is a [Lysand](https://lysand.org) server with the default extended description.
content: content:
'<p>This is a <a href="https://lysand.org">Lysand</a> server with the default extended description.</p>\n', '<p>This is a <a href="https://lysand.org">Lysand</a> server with the default extended description.</p>\n',

View file

@ -1,10 +1,8 @@
import { applyConfig, auth } from "@/api"; import { applyConfig, auth } from "@/api";
import { dualLogger } from "@/loggers"; import { renderMarkdownInPath } from "@/markdown";
import { jsonResponse } from "@/response"; import { jsonResponse } from "@/response";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { getMarkdownRenderer } from "~/database/entities/Status";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { LogLevel } from "~/packages/log-manager";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -24,36 +22,14 @@ export default (app: Hono) =>
meta.route, meta.route,
auth(meta.auth, meta.permissions), auth(meta.auth, meta.permissions),
async () => { async () => {
let extended_description = (await getMarkdownRenderer()).render( const { content, lastModified } = await renderMarkdownInPath(
config.instance.extended_description_path ?? "",
"This is a [Lysand](https://lysand.org) server with the default extended description.", "This is a [Lysand](https://lysand.org) server with the default extended description.",
); );
let lastModified = new Date(2024, 0, 0);
const extended_description_file = Bun.file(
config.instance.extended_description_path || "",
);
if (await extended_description_file.exists()) {
extended_description =
(await getMarkdownRenderer()).render(
(await extended_description_file
.text()
.catch(async (e) => {
await dualLogger.logError(
LogLevel.ERROR,
"Routes",
e,
);
return "";
})) ||
"This is a [Lysand](https://lysand.org) server with the default extended description.",
) || "";
lastModified = new Date(extended_description_file.lastModified);
}
return jsonResponse({ return jsonResponse({
updated_at: lastModified.toISOString(), updated_at: lastModified.toISOString(),
content: extended_description, content,
}); });
}, },
); );

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { sendTestRequest } from "~/tests/utils";
import { meta } from "./privacy_policy";
// /api/v1/instance/privacy_policy
describe(meta.route, () => {
test("should return privacy policy", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url)),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual({
updated_at: new Date(1970, 0, 0).toISOString(),
// This instance has not provided any privacy policy.
content:
"<p>This instance has not provided any privacy policy.</p>\n",
});
});
});

View file

@ -0,0 +1,35 @@
import { applyConfig, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { jsonResponse } from "@/response";
import type { Hono } from "hono";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/instance/privacy_policy",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
});
export default (app: Hono) =>
app.on(
meta.allowedMethods,
meta.route,
auth(meta.auth, meta.permissions),
async () => {
const { content, lastModified } = await renderMarkdownInPath(
config.instance.privacy_policy_path ?? "",
"This instance has not provided any privacy policy.",
);
return jsonResponse({
updated_at: lastModified.toISOString(),
content,
});
},
);

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { sendTestRequest } from "~/tests/utils";
import { meta } from "./tos";
// /api/v1/instance/tos
describe(meta.route, () => {
test("should return terms of service", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url)),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual({
updated_at: new Date(1970, 0, 0).toISOString(),
// This instance has not provided any terms of service.
content:
"<p>This instance has not provided any terms of service.</p>\n",
});
});
});

View file

@ -0,0 +1,35 @@
import { applyConfig, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { jsonResponse } from "@/response";
import type { Hono } from "hono";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["GET"],
route: "/api/v1/instance/tos",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
});
export default (app: Hono) =>
app.on(
meta.allowedMethods,
meta.route,
auth(meta.auth, meta.permissions),
async () => {
const { content, lastModified } = await renderMarkdownInPath(
config.instance.tos_path ?? "",
"This instance has not provided any terms of service.",
);
return jsonResponse({
updated_at: lastModified.toISOString(),
content,
});
},
);

31
utils/markdown.ts Normal file
View file

@ -0,0 +1,31 @@
import { markdownParse } from "~/database/entities/Status";
import { LogLevel } from "~/packages/log-manager";
import { dualLogger } from "./loggers";
export const renderMarkdownInPath = async (
path: string,
defaultText?: string,
) => {
let content = await markdownParse(defaultText ?? "");
let lastModified = new Date(1970, 0, 0);
const extended_description_file = Bun.file(path || "");
if (path && (await extended_description_file.exists())) {
content =
(await markdownParse(
(await extended_description_file.text().catch(async (e) => {
await dualLogger.logError(LogLevel.ERROR, "Routes", e);
return "";
})) ||
defaultText ||
"",
)) || "";
lastModified = new Date(extended_description_file.lastModified);
}
return {
content: content,
lastModified,
};
};