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

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>