From 86ab7374296e8a2847831acfc3ec7da37328829e Mon Sep 17 00:00:00 2001 From: thiloho <123883702+thiloho@users.noreply.github.com> Date: Fri, 20 Sep 2024 15:56:07 +0200 Subject: [PATCH] Add custom domain prefixes and option to disable user registration --- nix/deploy/qs/default.nix | 1 + nix/module.nix | 15 ++- .../20240805132306_last_modified_triggers.sql | 2 +- .../20240920090915_custom_domain_prefix.sql | 57 ++++++++++ web-app/src/lib/db-schema.ts | 52 +++++++-- web-app/src/lib/server/utils.ts | 6 ++ web-app/src/lib/utils.ts | 4 +- .../(anonymous)/register/+page.server.ts | 10 +- .../routes/(anonymous)/register/+page.svelte | 72 +++++++++---- .../routes/(authenticated)/+page.server.ts | 23 +++- .../[websiteId]/publish/+page.server.ts | 101 ++++++++++++++++-- .../website/[websiteId]/publish/+page.svelte | 82 +++++++++++--- 12 files changed, 368 insertions(+), 57 deletions(-) create mode 100644 rest-api/db/migrations/20240920090915_custom_domain_prefix.sql diff --git a/nix/deploy/qs/default.nix b/nix/deploy/qs/default.nix index dabd468..22e14dc 100644 --- a/nix/deploy/qs/default.nix +++ b/nix/deploy/qs/default.nix @@ -15,5 +15,6 @@ acmeEmail = "thilo.hohlt@tutanota.com"; dnsProvider = "porkbun"; dnsEnvironmentFile = /var/lib/porkbun.env; + disableRegistration = true; }; } diff --git a/nix/module.nix b/nix/module.nix index d31d2e5..52fc37c 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -75,6 +75,12 @@ in default = null; description = "API secrets for the DNS-01 challenge (required for wildcard domains)."; }; + + disableRegistration = mkOption { + type = types.bool; + default = false; + description = "By default any user can create an account. That behavior can be disabled by using this option."; + }; }; config = mkIf cfg.enable { @@ -125,7 +131,7 @@ in }; script = '' - BODY_SIZE_LIMIT=Infinity ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app + REGISTRATION_IS_DISABLED=${toString cfg.disableRegistration} BODY_SIZE_LIMIT=Infinity ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app ''; }; @@ -165,10 +171,13 @@ in "/api/" = { proxyPass = "http://localhost:${toString cfg.apiPort}/"; extraConfig = '' - default_type application/json; + default_type application/json; proxy_set_header Connection ""; proxy_http_version 1.1; - allow 127.0.0.1; + ''; + }; + "/api/rpc/register" = mkIf cfg.disableRegistration { + extraConfig = '' deny all; ''; }; diff --git a/rest-api/db/migrations/20240805132306_last_modified_triggers.sql b/rest-api/db/migrations/20240805132306_last_modified_triggers.sql index ea8794b..2714d42 100644 --- a/rest-api/db/migrations/20240805132306_last_modified_triggers.sql +++ b/rest-api/db/migrations/20240805132306_last_modified_triggers.sql @@ -68,7 +68,7 @@ CREATE TRIGGER update_footer_last_modified EXECUTE FUNCTION internal.update_last_modified (); CREATE TRIGGER update_legal_information_last_modified - BEFORE INSERT OR DELETE ON internal.legal_information + BEFORE INSERT OR UPDATE OR DELETE ON internal.legal_information FOR EACH ROW EXECUTE FUNCTION internal.update_last_modified (); diff --git a/rest-api/db/migrations/20240920090915_custom_domain_prefix.sql b/rest-api/db/migrations/20240920090915_custom_domain_prefix.sql new file mode 100644 index 0000000..fe4a428 --- /dev/null +++ b/rest-api/db/migrations/20240920090915_custom_domain_prefix.sql @@ -0,0 +1,57 @@ +-- migrate:up +CREATE TABLE internal.domain_prefix ( + website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE, + prefix VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(prefix) >= 3 AND prefix ~ '^[a-z]+(-[a-z]+)*$'), + created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(), + last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(), + last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL +); + +CREATE VIEW api.domain_prefix WITH ( security_invoker = ON +) AS +SELECT + * +FROM + internal.domain_prefix; + +GRANT SELECT, INSERT, UPDATE, DELETE ON internal.domain_prefix TO authenticated_user; + +GRANT SELECT, INSERT, UPDATE, DELETE ON api.domain_prefix TO authenticated_user; + +ALTER TABLE internal.domain_prefix ENABLE ROW LEVEL SECURITY; + +CREATE POLICY view_domain_prefix ON internal.domain_prefix + FOR SELECT + USING (internal.user_has_website_access (website_id, 10)); + +CREATE POLICY update_domain_prefix ON internal.domain_prefix + FOR UPDATE + USING (internal.user_has_website_access (website_id, 30)); + +CREATE POLICY delete_domain_prefix ON internal.domain_prefix + FOR DELETE + USING (internal.user_has_website_access (website_id, 30)); + +CREATE POLICY insert_domain_prefix ON internal.domain_prefix + FOR INSERT + WITH CHECK (internal.user_has_website_access (website_id, 30)); + +CREATE TRIGGER update_domain_prefix_last_modified + BEFORE INSERT OR UPDATE OR DELETE ON internal.domain_prefix + FOR EACH ROW + EXECUTE FUNCTION internal.update_last_modified (); + +CREATE TRIGGER domain_prefix_track_changes + AFTER INSERT OR UPDATE OR DELETE ON internal.domain_prefix + FOR EACH ROW + EXECUTE FUNCTION internal.track_changes (); + +-- migrate:down +DROP TRIGGER domain_prefix_track_changes ON internal.domain_prefix; + +DROP TRIGGER update_domain_prefix_last_modified ON internal.domain_prefix; + +DROP VIEW api.domain_prefix; + +DROP TABLE internal.domain_prefix; + diff --git a/web-app/src/lib/db-schema.ts b/web-app/src/lib/db-schema.ts index 1e45e9d..1706d8e 100644 --- a/web-app/src/lib/db-schema.ts +++ b/web-app/src/lib/db-schema.ts @@ -5,7 +5,7 @@ * AUTO-GENERATED FILE - DO NOT EDIT! * * This file was automatically generated by pg-to-ts v.4.1.1 - * $ pg-to-ts generate -c postgres://username:password@localhost:15432/archtika -t article -t change_log -t collab -t docs_category -t footer -t header -t home -t legal_information -t media -t settings -t user -t website -s internal + * $ pg-to-ts generate -c postgres://username:password@localhost:15432/archtika -t article -t change_log -t collab -t docs_category -t domain_prefix -t footer -t header -t home -t legal_information -t media -t settings -t user -t website -s internal * */ @@ -169,6 +169,7 @@ export interface DocsCategory { user_id: string | null; category_name: string; category_weight: number; + created_at: Date; last_modified_at: Date; last_modified_by: string | null; } @@ -178,6 +179,7 @@ export interface DocsCategoryInput { user_id?: string | null; category_name: string; category_weight: number; + created_at?: Date; last_modified_at?: Date; last_modified_by?: string | null; } @@ -189,6 +191,7 @@ const docs_category = { "user_id", "category_name", "category_weight", + "created_at", "last_modified_at", "last_modified_by" ], @@ -203,6 +206,34 @@ const docs_category = { $input: null as unknown as DocsCategoryInput } as const; +// Table domain_prefix +export interface DomainPrefix { + website_id: string; + prefix: string; + created_at: Date; + last_modified_at: Date; + last_modified_by: string | null; +} +export interface DomainPrefixInput { + website_id: string; + prefix: string; + created_at?: Date; + last_modified_at?: Date; + last_modified_by?: string | null; +} +const domain_prefix = { + tableName: "domain_prefix", + columns: ["website_id", "prefix", "created_at", "last_modified_at", "last_modified_by"], + requiredForInsert: ["website_id", "prefix"], + primaryKey: "website_id", + foreignKeys: { + website_id: { table: "website", column: "id", $type: null as unknown as Website }, + last_modified_by: { table: "user", column: "id", $type: null as unknown as User } + }, + $type: null as unknown as DomainPrefix, + $input: null as unknown as DomainPrefixInput +} as const; + // Table footer export interface Footer { website_id: string; @@ -297,18 +328,20 @@ const home = { export interface LegalInformation { website_id: string; main_content: string; + created_at: Date; last_modified_at: Date; last_modified_by: string | null; } export interface LegalInformationInput { website_id: string; main_content: string; + created_at?: Date; last_modified_at?: Date; last_modified_by?: string | null; } const legal_information = { tableName: "legal_information", - columns: ["website_id", "main_content", "last_modified_at", "last_modified_by"], + columns: ["website_id", "main_content", "created_at", "last_modified_at", "last_modified_by"], requiredForInsert: ["website_id", "main_content"], primaryKey: "website_id", foreignKeys: { @@ -395,16 +428,18 @@ export interface User { username: string; password_hash: string; role: string; + created_at: Date; } export interface UserInput { id?: string; username: string; password_hash: string; role?: string; + created_at?: Date; } const user = { tableName: "user", - columns: ["id", "username", "password_hash", "role"], + columns: ["id", "username", "password_hash", "role", "created_at"], requiredForInsert: ["username", "password_hash"], primaryKey: "id", foreignKeys: {}, @@ -418,8 +453,8 @@ export interface Website { user_id: string; content_type: string; title: string; - created_at: Date; is_published: boolean; + created_at: Date; last_modified_at: Date; last_modified_by: string | null; title_search: any | null; @@ -429,8 +464,8 @@ export interface WebsiteInput { user_id?: string; content_type: string; title: string; - created_at?: Date; is_published?: boolean; + created_at?: Date; last_modified_at?: Date; last_modified_by?: string | null; title_search?: any | null; @@ -442,8 +477,8 @@ const website = { "user_id", "content_type", "title", - "created_at", "is_published", + "created_at", "last_modified_at", "last_modified_by", "title_search" @@ -475,6 +510,10 @@ export interface TableTypes { select: DocsCategory; input: DocsCategoryInput; }; + domain_prefix: { + select: DomainPrefix; + input: DomainPrefixInput; + }; footer: { select: Footer; input: FooterInput; @@ -514,6 +553,7 @@ export const tables = { change_log, collab, docs_category, + domain_prefix, footer, header, home, diff --git a/web-app/src/lib/server/utils.ts b/web-app/src/lib/server/utils.ts index e745aeb..22d499a 100644 --- a/web-app/src/lib/server/utils.ts +++ b/web-app/src/lib/server/utils.ts @@ -3,3 +3,9 @@ import { dev } from "$app/environment"; export const API_BASE_PREFIX = dev ? "http://localhost:3000" : `${process.env.ORIGIN ? `${process.env.ORIGIN}/api` : "http://localhost:3000"}`; + +export const REGISTRATION_IS_DISABLED = dev + ? false + : process.env.REGISTRATION_IS_DISABLED + ? JSON.parse(process.env.REGISTRATION_IS_DISABLED) + : false; diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts index 1af3833..a16e396 100644 --- a/web-app/src/lib/utils.ts +++ b/web-app/src/lib/utils.ts @@ -12,7 +12,8 @@ import type { Footer, Article, DocsCategory, - LegalInformation + LegalInformation, + DomainPrefix } from "$lib/db-schema"; export const ALLOWED_MIME_TYPES = [ @@ -198,4 +199,5 @@ export interface WebsiteOverview extends Website { footer: Footer; article: (Article & { docs_category: DocsCategory | null })[]; legal_information?: LegalInformation; + domain_prefix?: DomainPrefix; } diff --git a/web-app/src/routes/(anonymous)/register/+page.server.ts b/web-app/src/routes/(anonymous)/register/+page.server.ts index d8dfc0c..4fd0aef 100644 --- a/web-app/src/routes/(anonymous)/register/+page.server.ts +++ b/web-app/src/routes/(anonymous)/register/+page.server.ts @@ -1,5 +1,11 @@ -import type { Actions } from "./$types"; -import { API_BASE_PREFIX } from "$lib/server/utils"; +import type { Actions, PageServerLoad } from "./$types"; +import { API_BASE_PREFIX, REGISTRATION_IS_DISABLED } from "$lib/server/utils"; + +export const load: PageServerLoad = async () => { + return { + REGISTRATION_IS_DISABLED + }; +}; export const actions: Actions = { default: async ({ request, fetch }) => { diff --git a/web-app/src/routes/(anonymous)/register/+page.svelte b/web-app/src/routes/(anonymous)/register/+page.svelte index 3c5a620..2ec07f3 100644 --- a/web-app/src/routes/(anonymous)/register/+page.svelte +++ b/web-app/src/routes/(anonymous)/register/+page.svelte @@ -1,10 +1,10 @@ @@ -15,24 +15,52 @@ {/if} -
{ - sending = true; - return async ({ update }) => { - await update(); - sending = false; - }; - }} -> - - +{#if data.REGISTRATION_IS_DISABLED} +

+ + + + Account registration is disabled on this instance +

+{:else} + { + sending = true; + return async ({ update }) => { + await update(); + sending = false; + }; + }} + > + + - -
+ + +{/if} + + diff --git a/web-app/src/routes/(authenticated)/+page.server.ts b/web-app/src/routes/(authenticated)/+page.server.ts index 2f37835..4482e00 100644 --- a/web-app/src/routes/(authenticated)/+page.server.ts +++ b/web-app/src/routes/(authenticated)/+page.server.ts @@ -103,6 +103,19 @@ export const actions: Actions = { deleteWebsite: async ({ request, cookies, fetch }) => { const data = await request.formData(); + const oldDomainPrefixData = await fetch( + `${API_BASE_PREFIX}/domain_prefix?website_id=eq.${data.get("id")}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cookies.get("session_token")}`, + Accept: "application/vnd.pgrst.object+json" + } + } + ); + const oldDomainPrefix = await oldDomainPrefixData.json(); + const res = await fetch(`${API_BASE_PREFIX}/website?id=eq.${data.get("id")}`, { method: "DELETE", headers: { @@ -116,11 +129,19 @@ export const actions: Actions = { return { success: false, message: response.message }; } - await rm(join("/", "var", "www", "archtika-websites", data.get("id") as string), { + await rm(join("/", "var", "www", "archtika-websites", "previews", data.get("id") as string), { recursive: true, force: true }); + await rm( + join("/", "var", "www", "archtika-websites", oldDomainPrefix.prefix ?? data.get("id")), + { + recursive: true, + force: true + } + ); + return { success: true, message: "Successfully deleted website" }; } }; diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.server.ts b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.server.ts index 674e6e7..08101b9 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.server.ts +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.server.ts @@ -1,4 +1,4 @@ -import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { readFile, mkdir, writeFile, rename } from "node:fs/promises"; import { join } from "node:path"; import { type WebsiteOverview, slugify } from "$lib/utils"; import type { Actions, PageServerLoad } from "./$types"; @@ -9,10 +9,11 @@ import BlogArticle from "$lib/templates/blog/BlogArticle.svelte"; import DocsIndex from "$lib/templates/docs/DocsIndex.svelte"; import DocsArticle from "$lib/templates/docs/DocsArticle.svelte"; import { dev } from "$app/environment"; +import type { DomainPrefixInput } from "$lib/db-schema"; export const load: PageServerLoad = async ({ params, fetch, cookies }) => { const websiteOverviewData = await fetch( - `${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`, + `${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`, { method: "GET", headers: { @@ -36,10 +37,13 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => { }/previews/${websiteOverview.id}/`; const websiteProdUrl = dev - ? `http://localhost:18000/${websiteOverview.id}/` + ? `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/` : process.env.ORIGIN - ? process.env.ORIGIN.replace("//", `//${websiteOverview.id}.`) - : `http://localhost:18000/${websiteOverview.id}/`; + ? process.env.ORIGIN.replace( + "//", + `//${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}.` + ) + : `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`; return { websiteOverview, @@ -51,7 +55,7 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => { export const actions: Actions = { publishWebsite: async ({ fetch, params, cookies }) => { const websiteOverviewData = await fetch( - `${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`, + `${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`, { method: "GET", headers: { @@ -82,10 +86,85 @@ export const actions: Actions = { } return { success: true, message: "Successfully published website" }; + }, + createUpdateCustomDomainPrefix: async ({ request, fetch, params, cookies }) => { + const data = await request.formData(); + + const oldDomainPrefixData = await fetch( + `${API_BASE_PREFIX}/domain_prefix?website_id=eq.${params.websiteId}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cookies.get("session_token")}`, + Accept: "application/vnd.pgrst.object+json" + } + } + ); + const oldDomainPrefix = await oldDomainPrefixData.json(); + + const res = await fetch(`${API_BASE_PREFIX}/domain_prefix`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cookies.get("session_token")}`, + Prefer: "resolution=merge-duplicates", + Accept: "application/vnd.pgrst.object+json" + }, + body: JSON.stringify({ + website_id: params.websiteId, + prefix: data.get("domain-prefix") as string + } satisfies DomainPrefixInput) + }); + + if (!res.ok) { + const response = await res.json(); + return { success: false, message: response.message }; + } + + await rename( + join( + "/", + "var", + "www", + "archtika-websites", + res.status === 201 ? params.websiteId : oldDomainPrefix.prefix + ), + join("/", "var", "www", "archtika-websites", data.get("domain-prefix") as string) + ); + + return { + success: true, + message: `Successfully ${res.status === 201 ? "created" : "updated"} domain prefix` + }; + }, + deleteCustomDomainPrefix: async ({ fetch, params, cookies }) => { + const res = await fetch(`${API_BASE_PREFIX}/domain_prefix?website_id=eq.${params.websiteId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cookies.get("session_token")}`, + Prefer: "return=representation", + Accept: "application/vnd.pgrst.object+json" + } + }); + + const response = await res.json(); + + if (!res.ok) { + return { success: false, message: response.message }; + } + + await rename( + join("/", "var", "www", "archtika-websites", response.prefix), + join("/", "var", "www", "archtika-websites", params.websiteId) + ); + + return { success: true, message: `Successfully deleted domain prefix` }; } }; -const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: boolean = true) => { +const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = true) => { const fileContents = (head: string, body: string) => { return ` @@ -112,7 +191,13 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: bool if (isPreview) { uploadDir = join("/", "var", "www", "archtika-websites", "previews", websiteData.id); } else { - uploadDir = join("/", "var", "www", "archtika-websites", websiteData.id); + uploadDir = join( + "/", + "var", + "www", + "archtika-websites", + websiteData.domain_prefix?.prefix ?? websiteData.id + ); } await mkdir(uploadDir, { recursive: true }); diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte index bd97a70..8df031b 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte @@ -4,6 +4,7 @@ import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import type { ActionData, PageServerData } from "./$types"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; + import Modal from "$lib/components/Modal.svelte"; const { data, form }: { data: PageServerData; form: ActionData } = $props(); @@ -45,18 +46,73 @@ > - - {#if data.websiteOverview.is_published} -
-

- Publication status -

-

- Your website is published at: -
- {data.websiteProdUrl} -

-
- {/if} + + {#if data.websiteOverview.is_published} +
+

+ Publication status +

+

+ Your website is published at: +
+ {data.websiteProdUrl} +

+
+ +
+

+ Custom domain prefix +

+
{ + sending = true; + return async ({ update }) => { + await update(); + sending = false; + }; + }} + > + + +
+ {#if data.websiteOverview.domain_prefix?.prefix} + +
{ + sending = true; + return async ({ update }) => { + await update(); + window.location.hash = "!"; + sending = false; + }; + }} + > +

Delete domain prefix

+

+ Caution! + This action will remove the domain prefix and reset it to its initial value. +

+ +
+
+ {/if} +
+ {/if}