Update README, row level policies for collaborators and articles and fix minor issues

This commit is contained in:
thiloho
2024-08-08 20:31:38 +02:00
parent 837729c83c
commit 8534b2d783
4 changed files with 94 additions and 75 deletions

View File

@@ -1,5 +1,20 @@
# archtika
## About
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates.
It is also possible to add contributors to your sites, which is very useful for larger projects where, for example, several people are constantly working on the documentation.
## How it works
For the backend, PostgreSQL is used in combination with PostgREST to create a RESTful API. JSON web tokens along with row-level security control authentication and authorisation flows.
The web application uses SvelteKit with SSR (Server Side Rendering) and Svelte version 5, currently in beta.
NGINX is used to deploy the websites, serving the static site files from the `/var/www/archtika-websites` directory. The static files can be found in this directory via the path `<user_id>/<website_id>`, which is dynamically created by the web application.
## Virtual machine for local development
The website directory used by the virtual machine needs to be created and the NodeJS process, which typically runs as the default system user, needs permission to write to this directory.

View File

@@ -143,15 +143,24 @@ BEGIN
INSERT INTO internal.home (website_id, main_content)
VALUES
(_website_id, '## Main content comes in here');
(_website_id, '
## About
INSERT INTO internal.article (website_id, user_id, title, meta_description, meta_author, main_content)
VALUES
(_website_id, _user_id, 'First article', 'This is the first sample article', 'Author Name', '## First article'),
(_website_id, _user_id, 'Second article', 'This is the second sample article', 'Author Name', '## Second article');
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates.
It is also possible to add contributors to your sites, which is very useful for larger projects where, for example, several people are constantly working on the documentation.
## How it works
For the backend, PostgreSQL is used in combination with PostgREST to create a RESTful API. JSON web tokens along with row-level security control authentication and authorisation flows.
The web application uses SvelteKit with SSR (Server Side Rendering) and Svelte version 5, currently in beta.
NGINX is used to deploy the websites, serving the static site files from the `/var/www/archtika-websites` directory. The static files can be found in this directory via the path `<user_id>/<website_id>`, which is dynamically created by the web application.
');
INSERT INTO internal.footer (website_id, additional_text)
VALUES (_website_id, 'This website was created with archtika');
VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS');
website_id := _website_id;
END;

View File

@@ -9,7 +9,14 @@ ALTER TABLE internal.article ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.footer ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
CREATE FUNCTION internal.user_has_website_access(website_id UUID, required_permission INTEGER DEFAULT 10)
CREATE FUNCTION internal.user_has_website_access(
website_id UUID,
required_permission INTEGER,
collaborator_permission_level INTEGER DEFAULT NULL,
collaborator_user_id UUID DEFAULT NULL,
article_user_id UUID DEFAULT NULL,
raise_error BOOLEAN DEFAULT true
)
RETURNS BOOLEAN AS $$
DECLARE
_user_id UUID;
@@ -33,8 +40,30 @@ BEGIN
WHERE c.website_id = user_has_website_access.website_id
AND c.user_id = (current_setting('request.jwt.claims', true)::json->>'user_id')::UUID
AND c.permission_level >= user_has_website_access.required_permission
AND (
user_has_website_access.article_user_id IS NULL
OR
(
c.permission_level = 30
OR
user_has_website_access.article_user_id = _user_id
)
)
AND (
user_has_website_access.collaborator_permission_level IS NULL
OR
(
user_has_website_access.collaborator_user_id != _user_id
AND
user_has_website_access.collaborator_permission_level < 30
)
)
) INTO _has_access;
IF NOT _has_access AND user_has_website_access.raise_error THEN
RAISE insufficient_privilege USING MESSAGE = 'You do not have the required permissions for this action.';
END IF;
RETURN _has_access;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
@@ -46,7 +75,7 @@ USING (true);
CREATE POLICY view_websites ON internal.website
FOR SELECT
USING (internal.user_has_website_access(id, 10));
USING (internal.user_has_website_access(id, 10, raise_error => false));
CREATE POLICY update_website ON internal.website
FOR UPDATE
@@ -103,15 +132,7 @@ USING (internal.user_has_website_access(website_id, 20));
CREATE POLICY delete_article ON internal.article
FOR DELETE
USING (
internal.user_has_website_access(website_id, 30)
OR
(
internal.user_has_website_access(website_id, 20)
AND
user_id = (current_setting('request.jwt.claims', true)::json->>'user_id')::UUID
)
);
USING (internal.user_has_website_access(website_id, 20, article_user_id => user_id));
CREATE POLICY insert_article ON internal.article
FOR INSERT
@@ -133,48 +154,15 @@ USING (internal.user_has_website_access(website_id, 10));
CREATE POLICY insert_collaborations ON internal.collab
FOR INSERT
WITH CHECK (
CASE
WHEN internal.user_has_website_access(website_id, 40) THEN
true
WHEN internal.user_has_website_access(website_id, 30) THEN
(user_id != (current_setting('request.jwt.claims', true)::json->>'user_id')::UUID)
AND
(permission_level < 30)
ELSE
false
END
);
WITH CHECK (internal.user_has_website_access(website_id, 30, collaborator_permission_level => permission_level, collaborator_user_id => user_id));
CREATE POLICY update_collaborations ON internal.collab
FOR UPDATE
USING (
CASE
WHEN internal.user_has_website_access(website_id, 40) THEN
true
WHEN internal.user_has_website_access(website_id, 30) THEN
(user_id != (current_setting('request.jwt.claims', true)::json->>'user_id')::UUID)
AND
(permission_level < 30)
ELSE
false
END
);
USING (internal.user_has_website_access(website_id, 30, collaborator_permission_level => permission_level, collaborator_user_id => user_id));
CREATE POLICY delete_collaborations ON internal.collab
FOR DELETE
USING (
CASE
WHEN internal.user_has_website_access(website_id, 40) THEN
TRUE
WHEN internal.user_has_website_access(website_id, 30) THEN
(user_id != (current_setting('request.jwt.claims', true)::json->>'user_id')::UUID)
AND
(permission_level < 30)
ELSE
FALSE
END
);
USING (internal.user_has_website_access(website_id, 30, collaborator_permission_level => permission_level, collaborator_user_id => user_id));
-- migrate:down
@@ -200,7 +188,7 @@ DROP POLICY view_collaborations ON internal.collab;
DROP POLICY insert_collaborations ON internal.collab;
DROP POLICY update_collaborations ON internal.collab;
DROP POLICY delete_collaborations ON internal.collab;
DROP FUNCTION internal.user_has_website_access(UUID, INTEGER);
DROP FUNCTION internal.user_has_website_access(UUID, INTEGER, INTEGER, UUID, UUID, BOOLEAN);
ALTER TABLE internal.user DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.website DISABLE ROW LEVEL SECURITY;

View File

@@ -55,28 +55,35 @@ const generateStaticFiles = async (websiteData: any, isPreview: boolean = true)
: `<img src="https://picsum.photos/32/32" />`
)
.replace("{{title}}", `<h1>${websiteData.title}</h1>`)
.replace("{{main_content}}", md.render(websiteData.main_content || ""))
.replace("{{main_content}}", md.render(websiteData.main_content ?? ""))
.replace(
"{{articles}}",
websiteData.articles
.map((article: { title: string; publication_date: string; meta_description: string }) => {
const articleFileName = article.title.toLowerCase().split(" ").join("-");
Array.isArray(websiteData.articles) && websiteData.articles.length > 0
? `
<h2>Articles</h2>
${websiteData.articles
.map(
(article: { title: string; publication_date: string; meta_description: string }) => {
const articleFileName = article.title.toLowerCase().split(" ").join("-");
return `
<article>
<p>${article.publication_date}</p>
<h3>
<a href="./articles/${articleFileName}.html">
${article.title}
</a>
</h3>
<p>${article.meta_description}</p>
</article>
`;
})
.join("")
return `
<article>
<p>${article.publication_date}</p>
<h3>
<a href="./articles/${articleFileName}.html">
${article.title}
</a>
</h3>
<p>${article.meta_description ?? "No description provided"}</p>
</article>
`;
}
)
.join("")}
`
: "<h2>Articles</h2><p>No articles available at this time.</p>"
)
.replace("{{additional_text}}", md.render(websiteData.additional_text || ""));
.replace("{{additional_text}}", md.render(websiteData.additional_text ?? ""));
let uploadDir = "";
@@ -92,7 +99,7 @@ const generateStaticFiles = async (websiteData: any, isPreview: boolean = true)
await mkdir(join(uploadDir, "articles"), { recursive: true });
for (const article of websiteData.articles) {
for (const article of websiteData.articles ?? []) {
const articleFileName = article.title.toLowerCase().split(" ").join("-");
const articleFileContents = articleFile
@@ -105,8 +112,8 @@ const generateStaticFiles = async (websiteData: any, isPreview: boolean = true)
.replace("{{cover_image}}", `<img src="https://picsum.photos/600/200" />`)
.replace("{{title}}", `<h1>${article.title}</h1>`)
.replace("{{publication_date}}", `<p>${article.publication_date}</p>`)
.replace("{{main_content}}", md.render(article.main_content || ""))
.replace("{{additional_text}}", md.render(websiteData.additional_text || ""));
.replace("{{main_content}}", md.render(article.main_content ?? ""))
.replace("{{additional_text}}", md.render(websiteData.additional_text ?? ""));
await writeFile(join(uploadDir, "articles", `${articleFileName}.html`), articleFileContents);
}