From ffcf01e3cd54dac79248753b6d66a85514260842 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 11 Jun 2024 09:55:40 -1000 Subject: [PATCH] feat(api): :sparkles: Add TOS and Privacy Policy support --- config/config.example.toml | 8 ++++- docs/api/index.md | 6 +++- docs/api/instance.md | 11 ++++++ docs/api/mastodon.md | 2 +- packages/config-manager/config.type.ts | 5 ++- .../v1/instance/extended_description.test.ts | 2 +- .../api/v1/instance/extended_description.ts | 32 +++-------------- .../api/v1/instance/privacy_policy.test.ts | 23 ++++++++++++ server/api/api/v1/instance/privacy_policy.ts | 35 +++++++++++++++++++ server/api/api/v1/instance/tos.test.ts | 23 ++++++++++++ server/api/api/v1/instance/tos.ts | 35 +++++++++++++++++++ utils/markdown.ts | 31 ++++++++++++++++ 12 files changed, 180 insertions(+), 33 deletions(-) create mode 100644 docs/api/instance.md create mode 100644 server/api/api/v1/instance/privacy_policy.test.ts create mode 100644 server/api/api/v1/instance/privacy_policy.ts create mode 100644 server/api/api/v1/instance/tos.test.ts create mode 100644 server/api/api/v1/instance/tos.ts create mode 100644 utils/markdown.ts diff --git a/config/config.example.toml b/config/config.example.toml index f5da5ecc..ecaae938 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -306,7 +306,13 @@ name = "Lysand" description = "A Lysand instance" # Path to a file containing a longer description of your instance # 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 # logo = "" # URL to your instance banner diff --git a/docs/api/index.md b/docs/api/index.md index 93867060..168a736b 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -25,4 +25,8 @@ For frontend developers. Please read [the documentation](./frontend.md). ## Mastodon API Extensions -Extra attributes have been added to some Mastodon API routes. Those changes are [documented in this document](./mastodon.md) \ No newline at end of file +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). \ No newline at end of file diff --git a/docs/api/instance.md b/docs/api/instance.md new file mode 100644 index 00000000..6609424b --- /dev/null +++ b/docs/api/instance.md @@ -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. \ No newline at end of file diff --git a/docs/api/mastodon.md b/docs/api/mastodon.md index f491b1f3..ac71da11 100644 --- a/docs/api/mastodon.md +++ b/docs/api/mastodon.md @@ -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 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` diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index bc6a1243..293c94c3 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -88,7 +88,6 @@ export const configValidator = z.object({ enabled: z.boolean().default(false), }), signups: z.object({ - tos_url: z.string().min(1).optional(), registration: z.boolean().default(true), rules: z.array(z.string()).default([]), }), @@ -475,6 +474,8 @@ export const configValidator = z.object({ name: z.string().min(1).default("Lysand"), description: z.string().min(1).default("A Lysand instance"), extended_description_path: z.string().optional(), + tos_path: z.string().optional(), + privacy_policy_path: z.string().optional(), logo: zUrl.optional(), banner: zUrl.optional(), }) @@ -482,6 +483,8 @@ export const configValidator = z.object({ name: "Lysand", description: "A Lysand instance", extended_description_path: undefined, + tos_path: undefined, + privacy_policy_path: undefined, logo: undefined, banner: undefined, }), diff --git a/server/api/api/v1/instance/extended_description.test.ts b/server/api/api/v1/instance/extended_description.test.ts index 9ab09e33..5156c97d 100644 --- a/server/api/api/v1/instance/extended_description.test.ts +++ b/server/api/api/v1/instance/extended_description.test.ts @@ -14,7 +14,7 @@ describe(meta.route, () => { const json = await response.json(); 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. content: '

This is a Lysand server with the default extended description.

\n', diff --git a/server/api/api/v1/instance/extended_description.ts b/server/api/api/v1/instance/extended_description.ts index 992ac851..c84c7bef 100644 --- a/server/api/api/v1/instance/extended_description.ts +++ b/server/api/api/v1/instance/extended_description.ts @@ -1,10 +1,8 @@ import { applyConfig, auth } from "@/api"; -import { dualLogger } from "@/loggers"; +import { renderMarkdownInPath } from "@/markdown"; import { jsonResponse } from "@/response"; import type { Hono } from "hono"; -import { getMarkdownRenderer } from "~/database/entities/Status"; import { config } from "~/packages/config-manager"; -import { LogLevel } from "~/packages/log-manager"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -24,36 +22,14 @@ export default (app: Hono) => meta.route, auth(meta.auth, meta.permissions), 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.", ); - 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({ updated_at: lastModified.toISOString(), - content: extended_description, + content, }); }, ); diff --git a/server/api/api/v1/instance/privacy_policy.test.ts b/server/api/api/v1/instance/privacy_policy.test.ts new file mode 100644 index 00000000..ed4360c3 --- /dev/null +++ b/server/api/api/v1/instance/privacy_policy.test.ts @@ -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: + "

This instance has not provided any privacy policy.

\n", + }); + }); +}); diff --git a/server/api/api/v1/instance/privacy_policy.ts b/server/api/api/v1/instance/privacy_policy.ts new file mode 100644 index 00000000..7ba3f31c --- /dev/null +++ b/server/api/api/v1/instance/privacy_policy.ts @@ -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, + }); + }, + ); diff --git a/server/api/api/v1/instance/tos.test.ts b/server/api/api/v1/instance/tos.test.ts new file mode 100644 index 00000000..91d60c5c --- /dev/null +++ b/server/api/api/v1/instance/tos.test.ts @@ -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: + "

This instance has not provided any terms of service.

\n", + }); + }); +}); diff --git a/server/api/api/v1/instance/tos.ts b/server/api/api/v1/instance/tos.ts new file mode 100644 index 00000000..c5986882 --- /dev/null +++ b/server/api/api/v1/instance/tos.ts @@ -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, + }); + }, + ); diff --git a/utils/markdown.ts b/utils/markdown.ts new file mode 100644 index 00000000..2e753442 --- /dev/null +++ b/utils/markdown.ts @@ -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, + }; +};