diff --git a/nix/dev-vm.nix b/nix/dev-vm.nix
index 773664a..48bacd4 100644
--- a/nix/dev-vm.nix
+++ b/nix/dev-vm.nix
@@ -68,7 +68,7 @@
];
locations = {
"/" = {
- root = "/var/www/archtika-websites";
+ root = "/var/www/archtika-websites/";
index = "index.html";
tryFiles = "$uri $uri/ $uri.html $uri/index.html index.html =404";
extraConfig = ''
diff --git a/rest-api/db/migrations/20240908104458_extra_page_table.sql b/rest-api/db/migrations/20240908104458_extra_page_table.sql
new file mode 100644
index 0000000..287562f
--- /dev/null
+++ b/rest-api/db/migrations/20240908104458_extra_page_table.sql
@@ -0,0 +1,55 @@
+-- migrate:up
+CREATE TABLE internal.legal_information (
+ website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
+ main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''),
+ last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
+ last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
+);
+
+CREATE VIEW api.legal_information WITH ( security_invoker = ON
+) AS
+SELECT
+ website_id,
+ main_content,
+ last_modified_at,
+ last_modified_by
+FROM
+ internal.legal_information;
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON internal.legal_information TO authenticated_user;
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON api.legal_information TO authenticated_user;
+
+ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY view_legal_information ON internal.legal_information
+ FOR SELECT
+ USING (internal.user_has_website_access (website_id, 10));
+
+CREATE POLICY update_legal_information ON internal.legal_information
+ FOR UPDATE
+ USING (internal.user_has_website_access (website_id, 30));
+
+CREATE POLICY delete_legal_information ON internal.legal_information
+ FOR DELETE
+ USING (internal.user_has_website_access (website_id, 30));
+
+CREATE POLICY insert_legal_information ON internal.legal_information
+ FOR INSERT
+ WITH CHECK (internal.user_has_website_access (website_id, 30));
+
+-- migrate:down
+DROP POLICY insert_legal_information ON internal.legal_information;
+
+DROP POLICY delete_legal_information ON internal.legal_information;
+
+DROP POLICY update_legal_information ON internal.legal_information;
+
+DROP POLICY view_legal_information ON internal.legal_information;
+
+ALTER TABLE internal.legal_information DISABLE ROW LEVEL SECURITY;
+
+DROP VIEW api.legal_information;
+
+DROP TABLE internal.legal_information;
+
diff --git a/rest-api/db/migrations/20240908115706_adjust_website_overview_extra_page.sql b/rest-api/db/migrations/20240908115706_adjust_website_overview_extra_page.sql
new file mode 100644
index 0000000..127813c
--- /dev/null
+++ b/rest-api/db/migrations/20240908115706_adjust_website_overview_extra_page.sql
@@ -0,0 +1,142 @@
+-- migrate:up
+CREATE OR REPLACE VIEW api.website_overview WITH ( security_invoker = ON
+) AS
+SELECT
+ w.id,
+ w.user_id,
+ w.content_type,
+ w.title,
+ s.accent_color_light_theme,
+ s.accent_color_dark_theme,
+ s.favicon_image,
+ h.logo_type,
+ h.logo_text,
+ h.logo_image,
+ ho.main_content,
+ f.additional_text,
+ (
+ SELECT
+ JSON_AGG(
+ JSON_BUILD_OBJECT(
+ 'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
+)
+)
+ FROM
+ internal.article a
+ WHERE
+ a.website_id = w.id
+) AS articles,
+ CASE WHEN w.content_type = 'Docs' THEN
+ (
+ SELECT
+ JSON_OBJECT_AGG(
+ COALESCE(
+ category_name, 'Uncategorized'
+), articles
+)
+ FROM (
+ SELECT
+ dc.category_name,
+ dc.category_weight AS category_weight,
+ JSON_AGG(
+ JSON_BUILD_OBJECT(
+ 'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
+) ORDER BY a.article_weight DESC NULLS LAST
+) AS articles
+ FROM
+ internal.article a
+ LEFT JOIN internal.docs_category dc ON a.category = dc.id
+ WHERE
+ a.website_id = w.id
+ GROUP BY
+ dc.id,
+ dc.category_name,
+ dc.category_weight
+ ORDER BY
+ category_weight DESC NULLS LAST
+) AS categorized_articles)
+ELSE
+ NULL
+ END AS categorized_articles,
+ li.main_content legal_information_main_content
+FROM
+ internal.website w
+ JOIN internal.settings s ON w.id = s.website_id
+ JOIN internal.header h ON w.id = h.website_id
+ JOIN internal.home ho ON w.id = ho.website_id
+ JOIN internal.footer f ON w.id = f.website_id
+ LEFT JOIN internal.legal_information li ON w.id = li.website_id;
+
+GRANT SELECT ON api.website_overview TO authenticated_user;
+
+-- migrate:down
+DROP VIEW api.website_overview;
+
+CREATE VIEW api.website_overview WITH ( security_invoker = ON
+) AS
+SELECT
+ w.id,
+ w.user_id,
+ w.content_type,
+ w.title,
+ s.accent_color_light_theme,
+ s.accent_color_dark_theme,
+ s.favicon_image,
+ h.logo_type,
+ h.logo_text,
+ h.logo_image,
+ ho.main_content,
+ f.additional_text,
+ (
+ SELECT
+ JSON_AGG(
+ JSON_BUILD_OBJECT(
+ 'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
+)
+)
+ FROM
+ internal.article a
+ WHERE
+ a.website_id = w.id
+) AS articles,
+ CASE WHEN w.content_type = 'Docs' THEN
+ (
+ SELECT
+ JSON_OBJECT_AGG(
+ COALESCE(
+ category_name, 'Uncategorized'
+), articles
+)
+ FROM (
+ SELECT
+ dc.category_name,
+ dc.category_weight AS category_weight,
+ JSON_AGG(
+ JSON_BUILD_OBJECT(
+ 'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
+) ORDER BY a.article_weight DESC NULLS LAST
+) AS articles
+ FROM
+ internal.article a
+ LEFT JOIN internal.docs_category dc ON a.category = dc.id
+ WHERE
+ a.website_id = w.id
+ GROUP BY
+ dc.id,
+ dc.category_name,
+ dc.category_weight
+ ORDER BY
+ category_weight DESC NULLS LAST
+) AS categorized_articles)
+ELSE
+ NULL
+ END AS categorized_articles
+FROM
+ internal.website w
+ JOIN internal.settings s ON w.id = s.website_id
+ JOIN internal.header h ON w.id = h.website_id
+ JOIN internal.home ho ON w.id = ho.website_id
+ JOIN internal.footer f ON w.id = f.website_id;
+
+GRANT SELECT ON api.website_overview TO authenticated_user;
+
diff --git a/web-app/src/lib/components/WebsiteEditor.svelte b/web-app/src/lib/components/WebsiteEditor.svelte
index 9715784..6280bd6 100644
--- a/web-app/src/lib/components/WebsiteEditor.svelte
+++ b/web-app/src/lib/components/WebsiteEditor.svelte
@@ -49,6 +49,9 @@
Collaborators
+
+ Legal information
+
Publish
diff --git a/web-app/src/lib/templates/blog/BlogArticle.svelte b/web-app/src/lib/templates/blog/BlogArticle.svelte
index e3a8adb..cc9b3d9 100644
--- a/web-app/src/lib/templates/blog/BlogArticle.svelte
+++ b/web-app/src/lib/templates/blog/BlogArticle.svelte
@@ -50,4 +50,4 @@
{/if}
-
+
diff --git a/web-app/src/lib/templates/common/Footer.svelte b/web-app/src/lib/templates/common/Footer.svelte
index df143f4..289b1fa 100644
--- a/web-app/src/lib/templates/common/Footer.svelte
+++ b/web-app/src/lib/templates/common/Footer.svelte
@@ -1,11 +1,14 @@
diff --git a/web-app/src/lib/templates/docs/DocsArticle.svelte b/web-app/src/lib/templates/docs/DocsArticle.svelte
index 2b48c32..b5d9df5 100644
--- a/web-app/src/lib/templates/docs/DocsArticle.svelte
+++ b/web-app/src/lib/templates/docs/DocsArticle.svelte
@@ -42,4 +42,4 @@
{/if}
-
+
diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts
index 75a6c66..1139da6 100644
--- a/web-app/src/lib/utils.ts
+++ b/web-app/src/lib/utils.ts
@@ -150,7 +150,7 @@ export const md = (markdownContent: string, showToc = true) => {
};
export const handleImagePaste = async (event: ClipboardEvent, API_BASE_PREFIX: string) => {
- const clipboardItems = Array.from(event.clipboardData?.items || []);
+ const clipboardItems = Array.from(event.clipboardData?.items ?? []);
const file = clipboardItems.find((item) => item.type.startsWith("image/"));
if (!file) return null;
diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/+page.svelte b/web-app/src/routes/(authenticated)/website/[websiteId]/+page.svelte
index fccabfe..3d3f91f 100644
--- a/web-app/src/routes/(authenticated)/website/[websiteId]/+page.svelte
+++ b/web-app/src/routes/(authenticated)/website/[websiteId]/+page.svelte
@@ -39,7 +39,8 @@
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
- {previewContent}
+ previewContent={previewContent ||
+ "Put some markdown content in main content to see a live preview here"}
previewScrollTop={textareaScrollTop}
>
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
new file mode 100644
index 0000000..dd804bd
--- /dev/null
+++ b/web-app/src/routes/(authenticated)/website/[websiteId]/legal-information/+page.server.ts
@@ -0,0 +1,80 @@
+import type { Actions, PageServerLoad } from "./$types";
+import { API_BASE_PREFIX } from "$lib/server/utils";
+import { rm } from "node:fs/promises";
+import { join } from "node:path";
+
+export const load: PageServerLoad = async ({ parent, fetch, params, cookies }) => {
+ const legalInformationData = await fetch(
+ `${API_BASE_PREFIX}/legal_information?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 legalInformation = legalInformationData.ok ? await legalInformationData.json() : null;
+ const { website } = await parent();
+
+ return {
+ legalInformation,
+ website
+ };
+};
+
+export const actions: Actions = {
+ createUpdateLegalInformation: async ({ request, fetch, cookies, params }) => {
+ const data = await request.formData();
+
+ const res = await fetch(`${API_BASE_PREFIX}/legal_information`, {
+ 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,
+ main_content: data.get("main-content")
+ })
+ });
+
+ if (!res.ok) {
+ const response = await res.json();
+ return { success: false, message: response.message };
+ }
+
+ return {
+ success: true,
+ message: `Successfully ${res.status === 201 ? "created" : "updated"} legal information`
+ };
+ },
+ deleteLegalInformation: async ({ fetch, cookies, params }) => {
+ const res = await fetch(
+ `${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${cookies.get("session_token")}`
+ }
+ }
+ );
+
+ if (!res.ok) {
+ const response = await res.json();
+ return { success: false, message: response.message };
+ }
+
+ await rm(
+ join("/", "var", "www", "archtika-websites", params.websiteId, "legal-information.html"),
+ { force: true }
+ );
+
+ return { success: true, message: `Successfully deleted legal information` };
+ }
+};
diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/legal-information/+page.svelte b/web-app/src/routes/(authenticated)/website/[websiteId]/legal-information/+page.svelte
new file mode 100644
index 0000000..7fbbece
--- /dev/null
+++ b/web-app/src/routes/(authenticated)/website/[websiteId]/legal-information/+page.svelte
@@ -0,0 +1,121 @@
+
+
+
+
+{#if sending}
+
+{/if}
+
+
+
+
+
+
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 cb68fd5..b6a09ae 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
@@ -32,6 +32,7 @@ interface WebsiteData {
categorized_articles: {
[key: string]: { title: string; publication_date: string; meta_description: string }[];
};
+ legal_information_main_content: string | null;
}
export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) => {
@@ -251,6 +252,67 @@ const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean
await writeFile(join(uploadDir, "articles", `${articleFileName}.html`), articleFileContents);
}
+ if (websiteData.legal_information_main_content) {
+ let head = "";
+ let body = "";
+
+ switch (websiteData.content_type) {
+ case "Blog":
+ {
+ ({ head, body } = render(BlogIndex, {
+ props: {
+ favicon: websiteData.favicon_image
+ ? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
+ : "",
+ title: "Legal information",
+ logoType: websiteData.logo_type,
+ logo:
+ websiteData.logo_type === "text"
+ ? (websiteData.logo_text ?? "")
+ : `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
+ mainContent: md(websiteData.legal_information_main_content ?? "", false),
+ articles: [],
+ footerAdditionalText: md(websiteData.additional_text ?? "")
+ }
+ }));
+ }
+ break;
+ case "Docs":
+ {
+ ({ head, body } = render(DocsIndex, {
+ props: {
+ favicon: websiteData.favicon_image
+ ? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
+ : "",
+ title: "Legal information",
+ logoType: websiteData.logo_type,
+ logo:
+ websiteData.logo_type === "text"
+ ? (websiteData.logo_text ?? "")
+ : `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
+ mainContent: md(websiteData.legal_information_main_content ?? "", false),
+ categorizedArticles: {},
+ footerAdditionalText: md(websiteData.additional_text ?? "")
+ }
+ }));
+ }
+ break;
+ }
+
+ const legalInformationFileContents = `
+
+
+
+ ${head}
+
+
+ ${body}
+
+`;
+
+ await writeFile(join(uploadDir, "legal-information.html"), legalInformationFileContents);
+ }
+
const commonStyles = await readFile(`${process.cwd()}/template-styles/common-styles.css`, {
encoding: "utf-8"
});
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 eaf69dd..023c769 100644
--- a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte
+++ b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte
@@ -47,7 +47,7 @@
{#if data.website.is_published}
-
+
diff --git a/web-app/tests/collaborator.spec.ts b/web-app/tests/collaborator.spec.ts
index a35ae89..828a978 100644
--- a/web-app/tests/collaborator.spec.ts
+++ b/web-app/tests/collaborator.spec.ts
@@ -97,6 +97,11 @@ test.describe.serial("Collaborator tests", () => {
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername);
await page.getByRole("button", { name: "Submit" }).click();
+
+ await page.getByRole("link", { name: "Legal information" }).click();
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content");
+ await page.getByRole("button", { name: "Submit" }).click();
});
for (const permissionLevel of permissionLevels) {
@@ -322,6 +327,45 @@ test.describe.serial("Collaborator tests", () => {
await expect(page.getByText("You do not have the required")).toBeVisible();
}
});
+ test("Create/Update legal information", async ({ page }) => {
+ await page.getByRole("link", { name: "Blog" }).click();
+ await page.getByRole("link", { name: "Legal information" }).click();
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content");
+ await page.getByRole("button", { name: "Submit" }).click();
+
+ if (permissionLevel === 30) {
+ await expect(page.getByText("Successfully created legal")).toBeVisible();
+ } else {
+ await expect(page.getByText("You do not have the required")).toBeVisible();
+ }
+
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content updated");
+ await page.getByRole("button", { name: "Submit" }).click();
+
+ if (permissionLevel === 30) {
+ await expect(page.getByText("Successfully updated legal")).toBeVisible();
+ } else {
+ await expect(page.getByText("You do not have the required")).toBeVisible();
+ }
+ });
+ test("Delete legal information", async ({ page }) => {
+ await page
+ .getByRole("link", {
+ name: [10, 20].includes(permissionLevel) ? "Documentation" : "Blog"
+ })
+ .click();
+ await page.getByRole("link", { name: "Legal information" }).click();
+ await page.getByRole("button", { name: "Delete" }).click();
+ await page.getByRole("button", { name: "Delete legal information" }).click();
+
+ if (permissionLevel === 30) {
+ await expect(page.getByText("Successfully deleted legal")).toBeVisible();
+ } else {
+ await expect(page.getByText("You do not have the required")).toBeVisible();
+ }
+ });
test("Create category", async ({ page }) => {
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
diff --git a/web-app/tests/website.spec.ts b/web-app/tests/website.spec.ts
index 5e68047..6a246ca 100644
--- a/web-app/tests/website.spec.ts
+++ b/web-app/tests/website.spec.ts
@@ -230,6 +230,29 @@ test.describe.serial("Website tests", () => {
await expect(page.getByText("Successfully removed")).toBeVisible();
});
});
+
+ test.describe.serial("Legal information", () => {
+ test("Create/Update legal information", async ({ authenticatedPage: page }) => {
+ await page.getByRole("link", { name: "Blog" }).click();
+ await page.getByRole("link", { name: "Legal information" }).click();
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content");
+ await page.getByRole("button", { name: "Submit" }).click();
+ await expect(page.getByText("Successfully created legal")).toBeVisible();
+
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
+ await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content updated");
+ await page.getByRole("button", { name: "Submit" }).click();
+ await expect(page.getByText("Successfully updated legal")).toBeVisible();
+ });
+ test("Delete legal information", async ({ authenticatedPage: page }) => {
+ await page.getByRole("link", { name: "Blog" }).click();
+ await page.getByRole("link", { name: "Legal information" }).click();
+ await page.getByRole("button", { name: "Delete" }).click();
+ await page.getByRole("button", { name: "Delete legal information" }).click();
+ await expect(page.getByText("Successfully deleted legal")).toBeVisible();
+ });
+ });
});
test.describe.serial("Docs", () => {