diff --git a/nix/deploy/qs/default.nix b/nix/deploy/qs/default.nix index 22e14dc..fad8b18 100644 --- a/nix/deploy/qs/default.nix +++ b/nix/deploy/qs/default.nix @@ -15,6 +15,10 @@ acmeEmail = "thilo.hohlt@tutanota.com"; dnsProvider = "porkbun"; dnsEnvironmentFile = /var/lib/porkbun.env; - disableRegistration = true; + settings = { + disableRegistration = true; + maxWebsiteStorageSize = 250; + maxUserWebsites = 3; + }; }; } diff --git a/nix/dev-vm.nix b/nix/dev-vm.nix index 4e8d313..42aef6c 100644 --- a/nix/dev-vm.nix +++ b/nix/dev-vm.nix @@ -52,6 +52,15 @@ postgresql = { enable = true; package = pkgs.postgresql_16; + /* + PL/Perl: + overrideAttrs ( + finalAttrs: previousAttrs: { + buildInputs = previousAttrs.buildInputs ++ [ pkgs.perl ]; + configureFlags = previousAttrs.configureFlags ++ [ "--with-perl" ]; + } + ); + */ ensureDatabases = [ "archtika" ]; authentication = lib.mkForce '' local all all trust diff --git a/nix/module.nix b/nix/module.nix index e14181d..84eeb8e 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -76,10 +76,26 @@ in 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."; + settings = mkOption { + type = types.submodule { + options = { + 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."; + }; + maxUserWebsites = mkOption { + type = types.int; + default = 2; + description = "Maximum number of websites allowed per user by default."; + }; + maxWebsiteStorageSize = mkOption { + type = types.int; + default = 500; + description = "Maximum amount of disk space in MB allowed per user website by default."; + }; + }; + }; }; }; @@ -91,7 +107,7 @@ in users.groups.${cfg.group} = { }; - systemd.tmpfiles.rules = [ "d /var/www/archtika-websites 0755 ${cfg.user} ${cfg.group} -" ]; + systemd.tmpfiles.rules = [ "d /var/www/archtika-websites 0777 ${cfg.user} ${cfg.group} -" ]; systemd.services.archtika-api = { description = "archtika API service"; @@ -112,6 +128,8 @@ in JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64) ${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.jwt_secret\" TO '$JWT_SECRET'" + ${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_storage_size\" TO ${toString cfg.settings.maxWebsiteStorageSize}" + ${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_number_user\" TO ${toString cfg.settings.maxUserWebsites}" ${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:5432/archtika?sslmode=disable --migrations-dir ${cfg.package}/rest-api/db/migrations up @@ -132,7 +150,7 @@ in }; script = '' - REGISTRATION_IS_DISABLED=${toString cfg.disableRegistration} BODY_SIZE_LIMIT=10M ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app + REGISTRATION_IS_DISABLED=${toString cfg.settings.disableRegistration} BODY_SIZE_LIMIT=10M ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app ''; }; @@ -189,7 +207,7 @@ in default_type application/json; ''; }; - "/api/rpc/register" = mkIf cfg.disableRegistration { + "/api/rpc/register" = mkIf cfg.settings.disableRegistration { extraConfig = '' deny all; ''; diff --git a/rest-api/db/migrations/20240719071602_main_tables.sql b/rest-api/db/migrations/20240719071602_main_tables.sql index a374607..fd0ef7c 100644 --- a/rest-api/db/migrations/20240719071602_main_tables.sql +++ b/rest-api/db/migrations/20240719071602_main_tables.sql @@ -29,7 +29,7 @@ ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; CREATE TABLE internal.user ( id UUID PRIMARY KEY DEFAULT gen_random_uuid (), - username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3), + username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'), password_hash CHAR(60) NOT NULL, user_role NAME NOT NULL DEFAULT 'authenticated_user', max_number_websites INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_number_user') ::INT, diff --git a/rest-api/db/migrations/20240720132802_exposed_views_functions.sql b/rest-api/db/migrations/20240720132802_exposed_views_functions.sql index 2835147..fa3a980 100644 --- a/rest-api/db/migrations/20240720132802_exposed_views_functions.sql +++ b/rest-api/db/migrations/20240720132802_exposed_views_functions.sql @@ -26,19 +26,7 @@ CREATE VIEW api.website WITH ( security_invoker = ON SELECT * FROM - internal.website AS w -WHERE - w.user_id = ( - CURRENT_SETTING( - 'request.jwt.claims', TRUE -)::JSON ->> 'user_id')::UUID - OR w.id IN ( - SELECT - c.website_id - FROM - internal.collab AS c - WHERE - c.user_id = (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID); + internal.website; CREATE VIEW api.settings WITH ( security_invoker = ON ) AS diff --git a/rest-api/db/migrations/20241006165029_administrator.sql b/rest-api/db/migrations/20241006165029_administrator.sql index 4eb0a9d..4e28b0b 100644 --- a/rest-api/db/migrations/20241006165029_administrator.sql +++ b/rest-api/db/migrations/20241006165029_administrator.sql @@ -146,22 +146,6 @@ CREATE TRIGGER _prevent_storage_excess_settings FOR EACH ROW EXECUTE FUNCTION internal.prevent_website_storage_size_excess (); -CREATE VIEW api.all_user_websites AS -SELECT - u.id AS user_id, - u.username, - u.created_at AS user_created_at, - u.max_number_websites, - COALESCE(JSONB_AGG(JSONB_BUILD_OBJECT('id', w.id, 'title', w.title, 'max_storage_size', w.max_storage_size) - ORDER BY w.created_at DESC) FILTER (WHERE w.id IS NOT NULL), '[]'::JSONB) AS websites -FROM - internal.user AS u - LEFT JOIN internal.website AS w ON u.id = w.user_id -GROUP BY - u.id; - -GRANT SELECT ON api.all_user_websites TO administrator; - GRANT UPDATE (max_storage_size) ON internal.website TO administrator; GRANT UPDATE, DELETE ON internal.user TO administrator; @@ -193,8 +177,6 @@ DROP TRIGGER _prevent_storage_excess_settings ON internal.settings; DROP FUNCTION internal.prevent_website_storage_size_excess; -DROP VIEW api.all_user_websites; - REVOKE UPDATE (max_storage_size) ON internal.website FROM administrator; REVOKE UPDATE, DELETE ON internal.user FROM administrator; diff --git a/rest-api/db/migrations/20241011092744_filesystem_triggers.sql b/rest-api/db/migrations/20241011092744_filesystem_triggers.sql new file mode 100644 index 0000000..0369006 --- /dev/null +++ b/rest-api/db/migrations/20241011092744_filesystem_triggers.sql @@ -0,0 +1,52 @@ +-- migrate:up +CREATE FUNCTION internal.cleanup_filesystem () + RETURNS TRIGGER + AS $$ +DECLARE + _website_id UUID; + _domain_prefix VARCHAR(16); +BEGIN + IF TG_TABLE_NAME = 'website' THEN + _website_id := OLD.id; + SELECT + d.prefix INTO _domain_prefix + FROM + internal.domain_prefix AS d + WHERE + d.website_id = _website_id; + EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf /var/www/archtika-websites/previews/%s''', _website_id); + EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf /var/www/archtika-websites/%s''', COALESCE(_domain_prefix, _website_id::VARCHAR)); + ELSE + _website_id := OLD.website_id; + SELECT + d.prefix INTO _domain_prefix + FROM + internal.domain_prefix AS d + WHERE + d.website_id = _website_id; + EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf /var/www/archtika-websites/previews/%s/legal-information.html''', _website_id); + EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf /var/www/archtika-websites/%s/legal-information.html''', COALESCE(_domain_prefix, _website_id::VARCHAR)); + END IF; + RETURN OLD; +END; +$$ +LANGUAGE plpgsql +SECURITY DEFINER; + +CREATE TRIGGER _cleanup_filesystem_website + BEFORE DELETE ON internal.website + FOR EACH ROW + EXECUTE FUNCTION internal.cleanup_filesystem (); + +CREATE TRIGGER _cleanup_filesystem_legal_information + BEFORE DELETE ON internal.legal_information + FOR EACH ROW + EXECUTE FUNCTION internal.cleanup_filesystem (); + +-- migrate:down +DROP TRIGGER _cleanup_filesystem_website ON internal.website; + +DROP TRIGGER _cleanup_filesystem_legal_information ON internal.legal_information; + +DROP FUNCTION internal.cleanup_filesystem; + diff --git a/web-app/src/lib/templates/blog/BlogIndex.svelte b/web-app/src/lib/templates/blog/BlogIndex.svelte index 2c66f11..9424d8f 100644 --- a/web-app/src/lib/templates/blog/BlogIndex.svelte +++ b/web-app/src/lib/templates/blog/BlogIndex.svelte @@ -32,7 +32,7 @@ {websiteUrl} /> - + diff --git a/web-app/src/lib/templates/common/Nav.svelte b/web-app/src/lib/templates/common/Nav.svelte index 3e7daa4..fb7a558 100644 --- a/web-app/src/lib/templates/common/Nav.svelte +++ b/web-app/src/lib/templates/common/Nav.svelte @@ -6,12 +6,14 @@ websiteOverview, isDocsTemplate, isIndexPage, - apiUrl + apiUrl, + isLegalPage }: { websiteOverview: WebsiteOverview; isDocsTemplate: boolean; isIndexPage: boolean; apiUrl: string; + isLegalPage?: boolean; } = $props(); const categorizedArticles = Object.fromEntries( @@ -70,7 +72,10 @@ {/if} - + {#if websiteOverview.header.logo_type === "text"} {websiteOverview.header.logo_text} {:else} diff --git a/web-app/src/lib/templates/docs/DocsIndex.svelte b/web-app/src/lib/templates/docs/DocsIndex.svelte index d332a09..a615387 100644 --- a/web-app/src/lib/templates/docs/DocsIndex.svelte +++ b/web-app/src/lib/templates/docs/DocsIndex.svelte @@ -26,7 +26,7 @@ {websiteUrl} /> - + diff --git a/web-app/src/routes/(anonymous)/register/+page.svelte b/web-app/src/routes/(anonymous)/register/+page.svelte index b8dcc08..5939ad5 100644 --- a/web-app/src/routes/(anonymous)/register/+page.svelte +++ b/web-app/src/routes/(anonymous)/register/+page.svelte @@ -38,7 +38,14 @@ Username: - + Password: diff --git a/web-app/src/routes/(authenticated)/+page.server.ts b/web-app/src/routes/(authenticated)/+page.server.ts index 1d43df6..1204953 100644 --- a/web-app/src/routes/(authenticated)/+page.server.ts +++ b/web-app/src/routes/(authenticated)/+page.server.ts @@ -1,8 +1,6 @@ import type { Actions, PageServerLoad } from "./$types"; import { apiRequest } from "$lib/server/utils"; import { API_BASE_PREFIX } from "$lib/server/utils"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; import type { Website } from "$lib/db-schema"; export const load: PageServerLoad = async ({ fetch, url, locals }) => { @@ -11,7 +9,7 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => { const params = new URLSearchParams(); - const baseFetchUrl = `${API_BASE_PREFIX}/website?order=last_modified_at.desc,created_at.desc`; + const baseFetchUrl = `${API_BASE_PREFIX}/website?select=*,collab(user_id)&collab.user_id=eq.${locals.user.id}&or=(user_id.eq.${locals.user.id},collab.not.is.null)&order=last_modified_at.desc,created_at.desc`; if (searchQuery) { params.append("title", `wfts.${searchQuery}`); @@ -77,15 +75,6 @@ export const actions: Actions = { const data = await request.formData(); const id = data.get("id"); - const oldDomainPrefix = ( - await apiRequest(fetch, `${API_BASE_PREFIX}/domain_prefix?website_id=eq.${id}`, "GET", { - headers: { - Accept: "application/vnd.pgrst.object+json" - }, - returnData: true - }) - ).data; - const deleteWebsite = await apiRequest( fetch, `${API_BASE_PREFIX}/website?id=eq.${id}`, @@ -99,16 +88,6 @@ export const actions: Actions = { return deleteWebsite; } - await rm(join("/", "var", "www", "archtika-websites", "previews", id as string), { - recursive: true, - force: true - }); - - await rm(join("/", "var", "www", "archtika-websites", oldDomainPrefix?.prefix ?? id), { - recursive: true, - force: true - }); - return deleteWebsite; } }; diff --git a/web-app/src/routes/(authenticated)/account/+page.server.ts b/web-app/src/routes/(authenticated)/account/+page.server.ts index e189032..f44e3b7 100644 --- a/web-app/src/routes/(authenticated)/account/+page.server.ts +++ b/web-app/src/routes/(authenticated)/account/+page.server.ts @@ -21,7 +21,7 @@ export const actions: Actions = { logout: async ({ cookies }) => { cookies.delete("session_token", { path: "/" }); - return { success: true, message: "Successfully logged out" }; + return { success: true, message: "Successfully logged out, you can refresh the page" }; }, deleteAccount: async ({ request, fetch, cookies }) => { const data = await request.formData(); diff --git a/web-app/src/routes/(authenticated)/account/+page.svelte b/web-app/src/routes/(authenticated)/account/+page.svelte index 4ceff51..d7697ed 100644 --- a/web-app/src/routes/(authenticated)/account/+page.svelte +++ b/web-app/src/routes/(authenticated)/account/+page.svelte @@ -33,7 +33,7 @@ -{#if data.storageSizes.data.length > 0} +{#if (data.storageSizes.data ?? []).length > 0} Storage diff --git a/web-app/src/routes/(authenticated)/manage/+page.server.ts b/web-app/src/routes/(authenticated)/manage/+page.server.ts index 3215a2e..3fb9be7 100644 --- a/web-app/src/routes/(authenticated)/manage/+page.server.ts +++ b/web-app/src/routes/(authenticated)/manage/+page.server.ts @@ -1,12 +1,13 @@ import type { Actions, PageServerLoad } from "./$types"; import { API_BASE_PREFIX } from "$lib/server/utils"; import { apiRequest } from "$lib/server/utils"; +import type { Website, User } from "$lib/db-schema"; export const load: PageServerLoad = async ({ fetch }) => { - const allUsers = ( + const usersWithWebsites: (User & { website: Website[] })[] = ( await apiRequest( fetch, - `${API_BASE_PREFIX}/all_user_websites?order=user_created_at.desc`, + `${API_BASE_PREFIX}/user?select=*,website!user_id(*)&order=created_at`, "GET", { returnData: true @@ -15,7 +16,7 @@ export const load: PageServerLoad = async ({ fetch }) => { ).data; return { - allUsers, + usersWithWebsites, API_BASE_PREFIX }; }; @@ -39,8 +40,6 @@ export const actions: Actions = { updateStorageLimit: async ({ request, fetch }) => { const data = await request.formData(); - console.log(`${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`); - return await apiRequest( fetch, `${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`, diff --git a/web-app/src/routes/(authenticated)/manage/+page.svelte b/web-app/src/routes/(authenticated)/manage/+page.svelte index fb36e3d..c951d1a 100644 --- a/web-app/src/routes/(authenticated)/manage/+page.svelte +++ b/web-app/src/routes/(authenticated)/manage/+page.svelte @@ -32,15 +32,15 @@ - {#each data.allUsers as { user_id, user_created_at, username, max_number_websites, websites }} + {#each data.usersWithWebsites as { id, created_at, username, max_number_websites, website }} - + - {user_id} + {id} {username} - + Manage user User "{username}" @@ -51,7 +51,7 @@ action="?/updateMaxWebsiteAmount" use:enhance={enhanceForm({ reset: false })} > - + Number of websites allowed: Submit - {#if websites.length > 0} + {#if website.length > 0} Websites - {#each websites as { id, title, max_storage_size }} + {#each website as { id, title, max_storage_size }} {title} @@ -105,7 +105,7 @@ action="?/deleteUser" use:enhance={enhanceForm({ closeModal: true })} > - + Delete user diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/+page.server.ts b/web-app/src/routes/(authenticated)/website/[websiteId]/+page.server.ts index 554c096..f9d0614 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/+page.server.ts +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/+page.server.ts @@ -4,37 +4,24 @@ import { apiRequest } from "$lib/server/utils"; import type { Settings, Header, Footer } from "$lib/db-schema"; export const load: PageServerLoad = async ({ params, fetch }) => { - const globalSettings: Settings = ( - await apiRequest( - fetch, - `${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`, - "GET", - { - headers: { - Accept: "application/vnd.pgrst.object+json" - }, - returnData: true - } - ) - ).data; - - const header: Header = ( - await apiRequest(fetch, `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, "GET", { - headers: { - Accept: "application/vnd.pgrst.object+json" - }, + const [globalSettingsResponse, headerResponse, footerResponse] = await Promise.all([ + apiRequest(fetch, `${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`, "GET", { + headers: { Accept: "application/vnd.pgrst.object+json" }, + returnData: true + }), + apiRequest(fetch, `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, "GET", { + headers: { Accept: "application/vnd.pgrst.object+json" }, + returnData: true + }), + apiRequest(fetch, `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, "GET", { + headers: { Accept: "application/vnd.pgrst.object+json" }, returnData: true }) - ).data; + ]); - const footer: Footer = ( - await apiRequest(fetch, `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, "GET", { - headers: { - Accept: "application/vnd.pgrst.object+json" - }, - returnData: true - }) - ).data; + const globalSettings: Settings = globalSettingsResponse.data; + const header: Header = headerResponse.data; + const footer: Footer = footerResponse.data; return { globalSettings, diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/articles/[articleId]/+page.svelte b/web-app/src/routes/(authenticated)/website/[websiteId]/articles/[articleId]/+page.svelte index ca168aa..3c26c8a 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/articles/[articleId]/+page.svelte +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/articles/[articleId]/+page.svelte @@ -44,14 +44,16 @@ - - Category: - - {#each data.categories as { id, category_name }} - {category_name} - {/each} - - + {#if data.categories.length > 0} + + Category: + + {#each data.categories as { id, category_name }} + {category_name} + {/each} + + + {/if} {/if} diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/legal-information/+page.server.ts b/web-app/src/routes/(authenticated)/website/[websiteId]/legal-information/+page.server.ts index 7188599..db61335 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/legal-information/+page.server.ts +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/legal-information/+page.server.ts @@ -1,7 +1,5 @@ import type { Actions, PageServerLoad } from "./$types"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; import type { LegalInformation } from "$lib/db-schema"; export const load: PageServerLoad = async ({ parent, fetch, params }) => { @@ -58,11 +56,6 @@ export const actions: Actions = { return deleteLegalInformation; } - await rm( - join("/", "var", "www", "archtika-websites", params.websiteId, "legal-information.html"), - { force: true } - ); - return deleteLegalInformation; } }; 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 06272a2..8150921 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 @@ -5,7 +5,7 @@ import BlogIndex from "$lib/templates/blog/BlogIndex.svelte"; import DocsArticle from "$lib/templates/docs/DocsArticle.svelte"; import DocsIndex from "$lib/templates/docs/DocsIndex.svelte"; import { type WebsiteOverview, hexToHSL, slugify } from "$lib/utils"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, writeFile, chmod, readdir } from "node:fs/promises"; import { join } from "node:path"; import { render } from "svelte/server"; import type { Actions, PageServerLoad } from "./$types"; @@ -290,5 +290,20 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru await writeFile(join(uploadDir, "common.css"), commonStyles); await writeFile(join(uploadDir, "scoped.css"), specificStyles); + await setPermissions(isPreview ? join(uploadDir, "../") : uploadDir); + return { websitePreviewUrl, websiteProdUrl }; }; + +const setPermissions = async (dir: string) => { + await chmod(dir, 0o777); + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await setPermissions(fullPath); + } else { + await chmod(fullPath, 0o777); + } + } +};
User "{username}"