Add ability to export articles, track publications in json file on NGINX, fix vulnerabilities and refactor

This commit is contained in:
thiloho
2024-11-19 18:49:40 +01:00
parent 037165947b
commit ada54c6f06
40 changed files with 844 additions and 1570 deletions

1060
web-app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
"@types/eslint__js": "8.42.3",
"@types/eslint-config-prettier": "6.11.3",
"@types/node": "22.5.5",
"eslint": "9.10.0",
"eslint": "9.15.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "2.44.0",
"globals": "15.9.0",
@@ -37,12 +37,15 @@
"typescript-eslint": "8.6.0",
"vite": "5.4.6"
},
"type": "module",
"dependencies": {
"diff-match-patch": "1.0.5",
"highlight.js": "11.10.0",
"isomorphic-dompurify": "2.15.0",
"marked": "14.1.2",
"marked-highlight": "2.1.4"
}
},
"overrides": {
"cookie": "0.7.0"
},
"type": "module"
}

View File

@@ -4,6 +4,12 @@
const dateObject = new Date(date);
const calcTimeAgo = (date: Date) => {
const secondsElapsed = (date.getTime() - Date.now()) / 1000;
if (Math.abs(secondsElapsed) < 1) {
return "Just now";
}
const formatter = new Intl.RelativeTimeFormat("en");
const ranges = [
["years", 60 * 60 * 24 * 365],
@@ -14,7 +20,6 @@
["minutes", 60],
["seconds", 1]
] as const;
const secondsElapsed = (date.getTime() - Date.now()) / 1000;
for (const [rangeType, rangeVal] of ranges) {
if (rangeVal < Math.abs(secondsElapsed)) {

View File

@@ -25,15 +25,7 @@
previewElement.scrollTop = (textareaScrollTop.value / 100) * scrollHeight;
});
const tabs = [
"settings",
"articles",
"categories",
"collaborators",
"legal-information",
"publish",
"logs"
];
const tabs = ["settings", "articles", "categories", "collaborators", "publish", "logs"];
</script>
<input type="checkbox" id="toggle-mobile-preview" hidden />

View File

@@ -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 domain_prefix -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 footer -t header -t home -t media -t settings -t user -t website -s internal
*
*/
@@ -206,34 +206,6 @@ const docs_category = {
$input: null as unknown as DocsCategoryInput
} as const;
// Table domain_prefix
export interface DomainPrefix {
website_id: string;
prefix: string;
created_at: string;
last_modified_at: string;
last_modified_by: string | null;
}
export interface DomainPrefixInput {
website_id: string;
prefix: string;
created_at?: string;
last_modified_at?: string;
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;
@@ -332,34 +304,6 @@ const home = {
$input: null as unknown as HomeInput
} as const;
// Table legal_information
export interface LegalInformation {
website_id: string;
main_content: string;
created_at: string;
last_modified_at: string;
last_modified_by: string | null;
}
export interface LegalInformationInput {
website_id: string;
main_content: string;
created_at?: string;
last_modified_at?: string;
last_modified_by?: string | null;
}
const legal_information = {
tableName: "legal_information",
columns: ["website_id", "main_content", "created_at", "last_modified_at", "last_modified_by"],
requiredForInsert: ["website_id", "main_content"],
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 LegalInformation,
$input: null as unknown as LegalInformationInput
} as const;
// Table media
export interface Media {
id: string;
@@ -469,8 +413,8 @@ export interface Website {
user_id: string;
content_type: string;
title: string;
slug: string | null;
max_storage_size: number;
is_published: boolean;
created_at: string;
last_modified_at: string;
last_modified_by: string | null;
@@ -480,8 +424,8 @@ export interface WebsiteInput {
user_id?: string;
content_type: string;
title: string;
slug?: string | null;
max_storage_size?: number;
is_published?: boolean;
created_at?: string;
last_modified_at?: string;
last_modified_by?: string | null;
@@ -493,8 +437,8 @@ const website = {
"user_id",
"content_type",
"title",
"slug",
"max_storage_size",
"is_published",
"created_at",
"last_modified_at",
"last_modified_by"
@@ -526,10 +470,6 @@ export interface TableTypes {
select: DocsCategory;
input: DocsCategoryInput;
};
domain_prefix: {
select: DomainPrefix;
input: DomainPrefixInput;
};
footer: {
select: Footer;
input: FooterInput;
@@ -542,10 +482,6 @@ export interface TableTypes {
select: Home;
input: HomeInput;
};
legal_information: {
select: LegalInformation;
input: LegalInformationInput;
};
media: {
select: Media;
input: MediaInput;
@@ -569,11 +505,9 @@ export const tables = {
change_log,
collab,
docs_category,
domain_prefix,
footer,
header,
home,
legal_information,
media,
settings,
user,

View File

@@ -19,11 +19,13 @@ export const apiRequest = async (
body?: any;
successMessage?: string;
returnData?: boolean;
noJSONTransform?: boolean;
} = {
headers: {},
body: undefined,
successMessage: "Operation was successful",
returnData: false
returnData: false,
noJSONTransform: false
}
) => {
const headers = {
@@ -48,7 +50,7 @@ export const apiRequest = async (
return {
success: true,
message: options.successMessage,
data: method === "HEAD" ? response : await response.json()
data: method === "HEAD" || options.noJSONTransform ? response : await response.json()
};
}

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { md, type WebsiteOverview } from "$lib/utils";
import type { Article } from "$lib/db-schema";
import Head from "$lib/templates/Head.svelte";
import Nav from "$lib/templates/Nav.svelte";
import Footer from "$lib/templates/Footer.svelte";
const {
websiteOverview,
article,
apiUrl,
websiteUrl
}: {
websiteOverview: WebsiteOverview;
article: Article;
apiUrl: string;
websiteUrl: string;
} = $props();
</script>
<Head
{websiteOverview}
nestingLevel={1}
{apiUrl}
title={article.title}
slug={article.slug as string}
metaDescription={article.meta_description}
{websiteUrl}
/>
<Nav
{websiteOverview}
isDocsTemplate={websiteOverview.content_type === "Docs"}
isIndexPage={false}
{apiUrl}
/>
<header>
<div class="container">
{#if websiteOverview.content_type === "Blog"}
<hgroup>
{#if article.publication_date}
<p>{article.publication_date}</p>
{/if}
<h1>{article.title}</h1>
</hgroup>
{#if article.cover_image}
<img src="{apiUrl}/rpc/retrieve_file?id={article.cover_image}" alt="" />
{/if}
{:else}
<h1>{article.title}</h1>
{/if}
</div>
</header>
{#if article.main_content}
<main>
<div class="container">
{@html md(article.main_content)}
</div>
</main>
{/if}
<Footer {websiteOverview} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { type WebsiteOverview, md } from "../utils";
const { websiteOverview }: { websiteOverview: WebsiteOverview } = $props();
</script>
<footer>
<div class="container">
{@html md(websiteOverview.footer.additional_text, false)}
</div>
</footer>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { type WebsiteOverview } from "../../utils";
import { type WebsiteOverview } from "../utils";
const {
websiteOverview,
@@ -22,7 +22,7 @@
const constructedTitle =
websiteOverview.title === title ? title : `${websiteOverview.title} | ${title}`;
let ogUrl = `${websiteUrl.replace(/\/$/, "")}${nestingLevel === 0 ? (websiteOverview.title === title ? "" : `/${slug}`) : `/articles/${slug}`}`;
const ogUrl = `${websiteUrl.replace(/\/$/, "")}${nestingLevel === 0 ? (websiteOverview.title === title ? "" : `/${slug}`) : `/articles/${slug}`}`;
</script>
<svelte:head>

View File

@@ -1,18 +1,16 @@
<script lang="ts">
import Head from "../common/Head.svelte";
import Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
import { md, type WebsiteOverview } from "$lib/utils";
import Head from "$lib/templates/Head.svelte";
import Nav from "$lib/templates/Nav.svelte";
import Footer from "$lib/templates/Footer.svelte";
const {
websiteOverview,
apiUrl,
isLegalPage,
websiteUrl
}: {
websiteOverview: WebsiteOverview;
apiUrl: string;
isLegalPage: boolean;
websiteUrl: string;
} = $props();
@@ -27,28 +25,29 @@
{websiteOverview}
nestingLevel={0}
{apiUrl}
title={isLegalPage ? "Legal information" : websiteOverview.title}
title={websiteOverview.title}
metaDescription={websiteOverview.home.meta_description}
{websiteUrl}
/>
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={true} {isLegalPage} {apiUrl} />
<Nav
{websiteOverview}
isDocsTemplate={websiteOverview.content_type === "Docs"}
isIndexPage={true}
{apiUrl}
/>
<header>
<div class="container">
<h1>{isLegalPage ? "Legal information" : websiteOverview.title}</h1>
<h1>{websiteOverview.title}</h1>
</div>
</header>
<main>
<div class="container">
{@html md(
isLegalPage
? (websiteOverview.legal_information?.main_content ?? "")
: websiteOverview.home.main_content,
false
)}
{#if websiteOverview.article.length > 0 && !isLegalPage}
{@html md(websiteOverview.home.main_content, false)}
{#if websiteOverview.article.length > 0 && websiteOverview.content_type === "Blog"}
<section class="articles" id="articles">
<h2>
<a href="#articles">Articles</a>
@@ -76,4 +75,4 @@
</div>
</main>
<Footer {websiteOverview} isIndexPage={true} />
<Footer {websiteOverview} />

View File

@@ -1,19 +1,17 @@
<script lang="ts">
import { type WebsiteOverview } from "../../utils";
import type { Article } from "../../db-schema";
import { type WebsiteOverview } from "../utils";
import type { Article } from "../db-schema";
const {
websiteOverview,
isDocsTemplate,
isIndexPage,
apiUrl,
isLegalPage
apiUrl
}: {
websiteOverview: WebsiteOverview;
isDocsTemplate: boolean;
isIndexPage: boolean;
apiUrl: string;
isLegalPage?: boolean;
} = $props();
const categorizedArticles = Object.fromEntries(
@@ -72,17 +70,14 @@
</ul>
</section>
{/if}
<svelte:element
this={isIndexPage && !isLegalPage ? "span" : "a"}
href={`${isLegalPage ? "./" : "../"}`}
>
<svelte:element this={isIndexPage ? "span" : "a"} href={`${isIndexPage ? "./" : "../"}`}>
{#if websiteOverview.header.logo_type === "text"}
<strong>{websiteOverview.header.logo_text}</strong>
{:else}
<img
src="{apiUrl}/rpc/retrieve_file?id={websiteOverview.header.logo_image}"
width="24"
height="24"
width="32"
height="32"
alt=""
/>
{/if}

View File

@@ -1,51 +0,0 @@
<script lang="ts">
import Head from "../common/Head.svelte";
import Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
import { type WebsiteOverview, md } from "../../utils";
import type { Article } from "../../db-schema";
const {
websiteOverview,
article,
apiUrl,
websiteUrl
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string; websiteUrl: string } =
$props();
</script>
<Head
{websiteOverview}
nestingLevel={1}
{apiUrl}
title={article.title}
slug={article.slug as string}
metaDescription={article.meta_description}
{websiteUrl}
/>
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={false} {apiUrl} />
<header>
<div class="container">
<hgroup>
{#if article.publication_date}
<p>{article.publication_date}</p>
{/if}
<h1>{article.title}</h1>
</hgroup>
{#if article.cover_image}
<img src="{apiUrl}/rpc/retrieve_file?id={article.cover_image}" alt="" />
{/if}
</div>
</header>
{#if article.main_content}
<main>
<div class="container">
{@html md(article.main_content)}
</div>
</main>
{/if}
<Footer {websiteOverview} isIndexPage={false} />

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import { type WebsiteOverview, md } from "../../utils";
const {
websiteOverview,
isIndexPage
}: { websiteOverview: WebsiteOverview; isIndexPage: boolean } = $props();
</script>
<footer>
<div class="container">
<small>
{@html md(websiteOverview.footer.additional_text, false).replace(
"!!legal",
`<a href="${isIndexPage ? "./legal-information" : "../legal-information"}">Legal information</a>`
)}
</small>
</div>
</footer>

View File

@@ -1,43 +0,0 @@
<script lang="ts">
import Head from "../common/Head.svelte";
import Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
import { md, type WebsiteOverview } from "../../utils";
import type { Article } from "../../db-schema";
const {
websiteOverview,
article,
apiUrl,
websiteUrl
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string; websiteUrl: string } =
$props();
</script>
<Head
{websiteOverview}
nestingLevel={1}
{apiUrl}
title={article.title}
slug={article.slug as string}
metaDescription={article.meta_description}
{websiteUrl}
/>
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={false} {apiUrl} />
<header>
<div class="container">
<h1>{article.title}</h1>
</div>
</header>
{#if article.main_content}
<main>
<div class="container">
{@html md(article.main_content)}
</div>
</main>
{/if}
<Footer {websiteOverview} isIndexPage={false} />

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import Head from "../common/Head.svelte";
import Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
import { md, type WebsiteOverview } from "../../utils";
const {
websiteOverview,
apiUrl,
isLegalPage,
websiteUrl
}: {
websiteOverview: WebsiteOverview;
apiUrl: string;
isLegalPage: boolean;
websiteUrl: string;
} = $props();
</script>
<Head
{websiteOverview}
nestingLevel={0}
{apiUrl}
title={isLegalPage ? "Legal information" : websiteOverview.title}
metaDescription={websiteOverview.home.meta_description}
{websiteUrl}
/>
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={true} {isLegalPage} {apiUrl} />
<header>
<div class="container">
<h1>{isLegalPage ? "Legal information" : websiteOverview.title}</h1>
</div>
</header>
<main>
<div class="container">
{@html md(
isLegalPage
? (websiteOverview.legal_information?.main_content ?? "")
: websiteOverview.home.main_content,
false
)}
</div>
</main>
<Footer {websiteOverview} isIndexPage={true} />

View File

@@ -11,8 +11,7 @@ import type {
Footer,
Article,
DocsCategory,
LegalInformation,
DomainPrefix
User
} from "$lib/db-schema";
import type { SubmitFunction } from "@sveltejs/kit";
import { sending } from "./runes.svelte";
@@ -221,6 +220,5 @@ export interface WebsiteOverview extends Website {
home: Home;
footer: Footer;
article: (Article & { docs_category: DocsCategory | null })[];
legal_information?: LegalInformation;
domain_prefix?: DomainPrefix;
user: User;
}

View File

@@ -77,27 +77,18 @@
</details>
<ul class="website-grid unpadded">
{#each data.websites as { id, user_id, content_type, title, created_at, last_modified_at, collab } (id)}
{#each data.websites as { id, user_id, content_type, title, last_modified_at, collab } (id)}
<li class="website-card">
<p>
<span>({content_type})</span>
<strong>
<a href="/website/{id}">{title}</a>
</strong>
</p>
<ul>
<li>
<strong>Type:</strong>
{content_type}
</li>
<li>
<strong>Created:</strong>
<DateTime date={created_at} />
</li>
<li>
<strong>Last modified:</strong>
<DateTime date={last_modified_at} />
</li>
</ul>
<p>
<strong>Last modified:</strong>
<DateTime date={last_modified_at} />
</p>
<div class="website-card__actions">
<Modal id="update-website-{id}" text="Update">
<h4>Update website</h4>

View File

@@ -73,7 +73,7 @@
<a
class="export-anchor"
href={`${data.API_BASE_PREFIX}/rpc/export_articles_zip?website_id=${data.website.id}`}
>Export articles</a
download>Export articles</a
>
<details>
<summary>Search & Filter</summary>

View File

@@ -1,72 +0,0 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { LegalInformation } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, fetch, params }) => {
const legalInformation: LegalInformation = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
)
).data;
const { website, permissionLevel } = await parent();
return {
legalInformation,
website,
API_BASE_PREFIX,
permissionLevel
};
};
export const actions: Actions = {
createUpdateLegalInformation: async ({ request, fetch, params }) => {
const data = await request.formData();
return await apiRequest(fetch, `${API_BASE_PREFIX}/legal_information`, "POST", {
headers: {
Prefer: "resolution=merge-duplicates",
Accept: "application/vnd.pgrst.object+json"
},
body: {
website_id: params.websiteId,
main_content: data.get("main-content")
},
successMessage: "Successfully created/updated legal information"
});
},
deleteLegalInformation: async ({ fetch, params }) => {
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
"DELETE",
{
successMessage: "Successfully deleted legal information"
}
);
},
pasteImage: async ({ request, fetch, params }) => {
const data = await request.formData();
const file = data.get("file") as File;
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
headers: {
"Content-Type": "application/octet-stream",
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId,
"X-Original-Filename": file.name
},
body: await file.arrayBuffer(),
successMessage: "Successfully uploaded image",
returnData: true
});
}
};

View File

@@ -1,94 +0,0 @@
<script lang="ts">
import { enhance } from "$app/forms";
import WebsiteEditor from "$lib/components/WebsiteEditor.svelte";
import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import Modal from "$lib/components/Modal.svelte";
import { enhanceForm } from "$lib/utils";
import { sending, previewContent } from "$lib/runes.svelte";
import type { ActionData, PageServerData } from "./$types";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props();
previewContent.value = data.legalInformation?.main_content ?? "";
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending.value}
<LoadingSpinner />
{/if}
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
>
<section id="legal-information">
<h2>
<a href="#legal-information">Legal information</a>
</h2>
<p>
Static websites that do not collect user data and do not use cookies generally have minimal
legal obligations regarding privacy policies, imprints, etc. However, it may still be a good
idea to include, for example:
</p>
<ol>
<li>A simple privacy policy stating that no personal information is collected or stored</li>
<li>
An imprint (if required by local law) with contact information for the site owner/operator
</li>
</ol>
<p>Always consult local laws and regulations for specific requirements in your jurisdiction.</p>
<p>
To include a link to your legal information in the footer, you can write <code>!!legal</code>.
</p>
<form
method="POST"
action="?/createUpdateLegalInformation"
use:enhance={enhanceForm({ reset: false })}
>
<MarkdownEditor
apiPrefix={data.API_BASE_PREFIX}
label="Main content"
name="main-content"
content={data.legalInformation?.main_content ?? ""}
/>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Update legal information</button
>
</form>
{#if data.legalInformation?.main_content}
<Modal id="delete-legal-information" text="Delete">
<form
action="?/deleteLegalInformation"
method="post"
use:enhance={enhanceForm({ closeModal: true })}
>
<h3>Delete legal information</h3>
<p>
<strong>Caution!</strong>
This action will remove the legal information page from the website and delete all data.
</p>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Delete legal information</button
>
</form>
</Modal>
{/if}
</section>
</WebsiteEditor>
<style>
form[action="?/createUpdateLegalInformation"] {
margin-block-start: var(--space-s);
}
</style>

View File

@@ -9,12 +9,17 @@ export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
const resourceFilter = url.searchParams.get("resource");
const operationFilter = url.searchParams.get("operation");
const currentPage = Number.parseInt(url.searchParams.get("page") ?? "1");
const sinceTime = url.searchParams.get("since");
const resultOffset = (currentPage - 1) * PAGINATION_MAX_ITEMS;
const searchParams = new URLSearchParams();
const baseFetchUrl = `${API_BASE_PREFIX}/change_log?website_id=eq.${params.websiteId}&select=id,table_name,operation,tstamp,old_value,new_value,user_id,username&order=tstamp.desc`;
if (sinceTime) {
searchParams.append("tstamp", `gt.${sinceTime}`);
}
if (userFilter && userFilter !== "all") {
searchParams.append("username", `eq.${userFilter}`);
}

View File

@@ -96,6 +96,7 @@
</select>
</label>
<input type="hidden" name="page" value={1} />
<input type="hidden" name="since" value={$page.url.searchParams.get("since")} />
<button type="submit">Apply</button>
</form>
</details>
@@ -163,7 +164,7 @@
</table>
</div>
<Pagination
commonFilters={["user", "resource", "operation"]}
commonFilters={["user", "resource", "operation", "since"]}
resultCount={data.resultChangeLogCount}
/>
</section>

View File

@@ -1,17 +1,15 @@
import { dev } from "$app/environment";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import BlogArticle from "$lib/templates/blog/BlogArticle.svelte";
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 Index from "$lib/templates/Index.svelte";
import Article from "$lib/templates/Article.svelte";
import { type WebsiteOverview, hexToHSL } from "$lib/utils";
import { mkdir, readFile, writeFile, chmod, readdir } from "node:fs/promises";
import { mkdir, writeFile, chmod, readdir, rm, readFile } from "node:fs/promises";
import { join } from "node:path";
import { render } from "svelte/server";
import type { Actions, PageServerLoad } from "./$types";
const getOverviewFetchUrl = (websiteId: string) => {
return `${API_BASE_PREFIX}/website?id=eq.${websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`;
return `${API_BASE_PREFIX}/website?id=eq.${websiteId}&select=*,user!user_id(*),settings(*),header(*),home(*),footer(*),article(*,docs_category(*))`;
};
export const load: PageServerLoad = async ({ params, fetch, parent }) => {
@@ -25,19 +23,37 @@ export const load: PageServerLoad = async ({ params, fetch, parent }) => {
).data;
const { websitePreviewUrl, websiteProdUrl } = await generateStaticFiles(websiteOverview);
const prodIsGenerated = (await fetch(websiteProdUrl, { method: "HEAD" })).ok;
const { permissionLevel } = await parent();
let currentMeta = null;
try {
const metaPath = join(
"/var/www/archtika-websites",
websiteOverview.user.username,
websiteOverview.slug as string,
".publication-meta.json"
);
const metaContent = await readFile(metaPath, "utf-8");
currentMeta = JSON.parse(metaContent);
} catch {
currentMeta = null;
}
const { website, permissionLevel } = await parent();
return {
websiteOverview,
websitePreviewUrl,
websiteProdUrl,
permissionLevel
permissionLevel,
prodIsGenerated,
currentMeta,
website
};
};
export const actions: Actions = {
publishWebsite: async ({ fetch, params }) => {
publishWebsite: async ({ fetch, params, locals }) => {
const websiteOverview: WebsiteOverview = (
await apiRequest(fetch, getOverviewFetchUrl(params.websiteId), "GET", {
headers: {
@@ -47,48 +63,39 @@ export const actions: Actions = {
})
).data;
const publish = await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`,
"PATCH",
{
body: {
is_published: true
},
successMessage: "Successfully published website"
}
);
let permissionLevel = 40;
if (!publish.success) {
return publish;
if (websiteOverview.user_id !== locals.user.id) {
permissionLevel = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/collab?select=permission_level&website_id=eq.${params.websiteId}&user_id=eq.${locals.user.id}`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
)
).data.permission_level;
}
await generateStaticFiles(websiteOverview, false);
if (permissionLevel < 30) {
return { success: false, message: "Insufficient permissions" };
}
return publish;
},
createUpdateCustomDomainPrefix: async ({ request, fetch, params }) => {
const data = await request.formData();
await generateStaticFiles(websiteOverview, false, fetch);
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/set_domain_prefix`, "POST", {
body: {
website_id: params.websiteId,
prefix: data.get("domain-prefix")
},
successMessage: "Successfully created/updated domain prefix"
});
},
deleteCustomDomainPrefix: async ({ fetch, params }) => {
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/delete_domain_prefix`, "POST", {
body: {
website_id: params.websiteId
},
successMessage: "Successfully deleted domain prefix"
});
return { success: true, message: "Successfully published website" };
}
};
const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = true) => {
const generateStaticFiles = async (
websiteData: WebsiteOverview,
isPreview = true,
customFetch: typeof fetch = fetch
) => {
const websitePreviewUrl = `${
dev
? "http://localhost:18000"
@@ -98,13 +105,10 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
}/previews/${websiteData.id}/`;
const websiteProdUrl = dev
? `http://localhost:18000/${websiteData.domain_prefix?.prefix ?? websiteData.id}/`
? `http://localhost:18000/${websiteData.user.username}/${websiteData.slug}`
: process.env.ORIGIN
? process.env.ORIGIN.replace(
"//",
`//${websiteData.domain_prefix?.prefix ?? websiteData.id}.`
)
: `http://localhost:18000/${websiteData.domain_prefix?.prefix ?? websiteData.id}/`;
? `${process.env.ORIGIN.replace("//", `//${websiteData.user.username}.`)}/${websiteData.slug}`
: `http://localhost:18000/${websiteData.user.username}/${websiteData.slug}`;
const fileContents = (head: string, body: string) => {
return `
@@ -119,11 +123,10 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
</html>`;
};
const { head, body } = render(websiteData.content_type === "Blog" ? BlogIndex : DocsIndex, {
const { head, body } = render(Index, {
props: {
websiteOverview: websiteData,
apiUrl: API_BASE_PREFIX,
isLegalPage: false,
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
}
});
@@ -132,24 +135,60 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
if (isPreview) {
uploadDir = join("/", "var", "www", "archtika-websites", "previews", websiteData.id);
await mkdir(uploadDir, { recursive: true });
} else {
uploadDir = join(
"/",
"var",
"www",
"archtika-websites",
websiteData.domain_prefix?.prefix ?? websiteData.id
websiteData.user.username,
websiteData.slug ?? websiteData.id
);
const articlesDir = join(uploadDir, "articles");
let existingArticles: string[] = [];
try {
existingArticles = await readdir(articlesDir);
} catch {
existingArticles = [];
}
const currentArticleSlugs = websiteData.article?.map((article) => `${article.slug}.html`) ?? [];
for (const file of existingArticles) {
if (!currentArticleSlugs.includes(file)) {
await rm(join(articlesDir, file));
}
}
const latestChange = await apiRequest(
customFetch,
`${API_BASE_PREFIX}/change_log?website_id=eq.${websiteData.id}&order=tstamp.desc&limit=1`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
);
const meta = {
lastPublishedAt: new Date().toISOString(),
lastChangeLogId: latestChange?.data?.id
};
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, ".publication-meta.json"), JSON.stringify(meta, null, 2));
}
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, "index.html"), fileContents(head, body));
await mkdir(join(uploadDir, "articles"), {
recursive: true
});
for (const article of websiteData.article ?? []) {
const { head, body } = render(websiteData.content_type === "Blog" ? BlogArticle : DocsArticle, {
const { head, body } = render(Article, {
props: {
websiteOverview: websiteData,
article,
@@ -161,19 +200,6 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
await writeFile(join(uploadDir, "articles", `${article.slug}.html`), fileContents(head, body));
}
if (websiteData.legal_information) {
const { head, body } = render(websiteData.content_type === "Blog" ? BlogIndex : DocsIndex, {
props: {
websiteOverview: websiteData,
apiUrl: API_BASE_PREFIX,
isLegalPage: true,
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
}
});
await writeFile(join(uploadDir, "legal-information.html"), fileContents(head, body));
}
const variableStyles = await readFile(`${process.cwd()}/template-styles/variables.css`, {
encoding: "utf-8"
});
@@ -237,7 +263,7 @@ 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);
await setPermissions(join(uploadDir, "../"));
return { websitePreviewUrl, websiteProdUrl };
};

View File

@@ -4,10 +4,9 @@
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";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
import { previewContent } from "$lib/runes.svelte";
import { enhanceForm } from "$lib/utils";
const { data, form }: { data: PageServerData; form: ActionData } = $props();
@@ -31,10 +30,16 @@
<a href="#publish-website">Publish website</a>
</h2>
<p>
The preview area on this page allows you to see exactly how your website will look when it is
is published. If you are happy with the results, click the button below and your website will
be published on the Internet.
Whenever you make changes, you will need to click the button below to make them visible on the
published website.
</p>
{#if data.currentMeta}
<a
class="latest-changes-anchor"
href="/website/{data.website.id}/logs?since={data.currentMeta.lastPublishedAt}"
>Changes since last publication</a
>
{/if}
<form method="POST" action="?/publishWebsite" use:enhance={enhanceForm()}>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Publish website</button
@@ -42,7 +47,7 @@
</form>
</section>
{#if data.websiteOverview.is_published}
{#if data.prodIsGenerated}
<section id="publication-status">
<h2>
<a href="#publication-status">Publication status</a>
@@ -52,51 +57,11 @@
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
</p>
</section>
<section id="custom-domain-prefix">
<h2>
<a href="#custom-domain-prefix">Custom domain prefix</a>
</h2>
<form
method="POST"
action="?/createUpdateCustomDomainPrefix"
use:enhance={enhanceForm({ reset: false })}
>
<label>
Prefix:
<input
type="text"
name="domain-prefix"
value={data.websiteOverview.domain_prefix?.prefix ?? ""}
placeholder="my-blog"
minlength="3"
maxlength="16"
pattern="^(?!previews$)[a-z]+(-[a-z]+)*$"
required
/>
</label>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Update domain prefix</button
>
</form>
{#if data.websiteOverview.domain_prefix?.prefix}
<Modal id="delete-domain-prefix" text="Delete">
<form
action="?/deleteCustomDomainPrefix"
method="post"
use:enhance={enhanceForm({ closeModal: true })}
>
<h3>Delete domain prefix</h3>
<p>
<strong>Caution!</strong>
This action will remove the domain prefix and reset it to its initial value.
</p>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Delete domain prefix</button
>
</form>
</Modal>
{/if}
</section>
{/if}
</WebsiteEditor>
<style>
.latest-changes-anchor {
max-inline-size: fit-content;
}
</style>

View File

@@ -34,14 +34,21 @@ header img {
nav,
header,
main,
footer {
main {
padding-block: var(--space-s);
}
main {
padding-block-end: var(--space-xl);
}
footer {
margin-block-start: auto;
text-align: center;
}
footer > .container {
border-block-start: 0.125rem dotted var(--color-border);
padding-block: var(--space-s);
}
.articles ul {

View File

@@ -278,7 +278,6 @@ table {
th,
td {
text-align: start;
padding: var(--space-2xs);
border: var(--border-primary);
}

View File

@@ -27,14 +27,21 @@ header > .container {
nav,
header,
main,
footer {
main {
padding-block: var(--space-s);
}
main {
padding-block-end: var(--space-xl);
}
footer {
margin-block-start: auto;
text-align: center;
}
footer > .container {
border-block-start: 0.125rem dotted var(--color-border);
padding-block: var(--space-s);
}
section {

View File

@@ -1,109 +0,0 @@
import { test, expect } from "@playwright/test";
import {
userOwner,
authenticate,
permissionLevels,
collabUsers,
collabTestingWebsite
} from "./shared";
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Legal information" }).click();
});
test(`Create/update legal information`, async ({ page }) => {
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Update legal information" }).click();
await expect(page.getByText("Successfully created/updated legal information")).toBeVisible();
});
test(`Delete legal information`, async ({ page }) => {
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Arbitrary content");
await page.getByRole("button", { name: "Update 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 information")).toBeVisible();
});
});
for (const permissionLevel of permissionLevels) {
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
test(`Create/update legal information`, async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Legal information" }).click();
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Random content");
await page
.getByRole("button", { name: "Update legal information" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update legal information" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(
page.getByText("Successfully created/updated legal information")
).toBeVisible();
}
});
test(`Delete legal information`, async ({ page, browserName }) => {
test.skip(browserName === "firefox", "Some issues with Firefox in headful mode");
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Legal information" }).click();
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Even more content");
await page.getByRole("button", { name: "Update legal information" }).click();
await page.waitForResponse(/createUpdateLegalInformation/);
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Logout" }).click();
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Legal information" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await page
.getByRole("button", { name: "Delete legal information" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Delete legal information" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully deleted legal information")).toBeVisible();
}
});
});
}

View File

@@ -23,25 +23,6 @@ test.describe("Website owner", () => {
await expect(page.getByText("Successfully published website")).toBeVisible();
await expect(page.getByText("Your website is published at")).toBeVisible();
});
test(`Set custom domain prefix`, async ({ page }) => {
await page.getByLabel("Prefix:").click();
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
await page.getByLabel("Prefix:").fill("example-prefix");
await page.getByRole("button", { name: "Update domain prefix" }).click();
await expect(page.getByText("Successfully created/updated domain prefix")).toBeVisible();
});
test(`Delete custom domain prefix`, async ({ page }) => {
await page.getByLabel("Prefix:").click();
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
await page.getByLabel("Prefix:").fill("example-prefix");
await page.getByRole("button", { name: "Update domain prefix" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await page.getByRole("button", { name: "Delete domain prefix" }).click();
await expect(page.getByText("Successfully deleted domain prefix")).toBeVisible();
});
});
for (const permissionLevel of permissionLevels) {
@@ -67,69 +48,5 @@ for (const permissionLevel of permissionLevels) {
await expect(page.getByText("Your website is published at")).toBeVisible();
}
});
test(`Set custom domain prefix`, async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByLabel("Prefix:").click();
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
await page.getByLabel("Prefix:").fill("new-prefix");
await page
.getByRole("button", { name: "Update domain prefix" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update domain prefix" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully created/updated domain prefix")).toBeVisible();
}
});
test(`Delete custom domain prefix`, async ({ page, browserName }) => {
test.skip(browserName === "firefox", "Some issues with Firefox in headful mode");
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByLabel("Prefix:").click();
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
await page.getByLabel("Prefix:").fill("new-prefix");
await page.getByRole("button", { name: "Update domain prefix" }).click();
await page.waitForResponse(/createUpdateCustomDomainPrefix/);
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Logout" }).click();
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await page
.getByRole("button", { name: "Delete domain prefix" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Delete domain prefix" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully deleted domain prefix")).toBeVisible();
}
});
});
}