Merge pull request #19 from archtika/devel

Fix some remaining issues and harden services for better security
This commit is contained in:
Thilo Hohlt
2024-12-08 17:17:15 +01:00
committed by GitHub
54 changed files with 1186 additions and 1663 deletions

View File

@@ -95,10 +95,22 @@
tryFiles = "$uri $uri/ $uri.html =404";
};
};
extraConfig = ''
port_in_redirect off;
absolute_redirect off;
'';
};
};
};
systemd.services.postgresql = {
path = with pkgs; [
# Tar and gzip are needed for tar.gz exports
gnutar
gzip
];
};
services.getty.autologinUser = "dev";
system.stateVersion = "24.05";

View File

@@ -9,6 +9,29 @@ with lib;
let
cfg = config.services.archtika;
baseHardenedSystemdOptions = {
CapabilityBoundingSet = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RemoveIPC = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = ["@system-service" "~@privileged" "~@resources"];
ReadWritePaths = ["/var/www/archtika-websites"];
};
in
{
options.services.archtika = {
@@ -105,9 +128,17 @@ in
group = cfg.group;
};
users.groups.${cfg.group} = { };
users.groups.${cfg.group} = {
members = [
"nginx"
"postgres"
];
};
systemd.tmpfiles.rules = [ "d /var/www/archtika-websites 0777 ${cfg.user} ${cfg.group} -" ];
systemd.tmpfiles.rules = [
"d /var/www 0755 root root -"
"d /var/www/archtika-websites 0770 ${cfg.user} ${cfg.group} -"
];
systemd.services.archtika-api = {
description = "archtika API service";
@@ -117,11 +148,13 @@ in
"postgresql.service"
];
serviceConfig = {
serviceConfig = baseHardenedSystemdOptions // {
User = cfg.user;
Group = cfg.group;
Restart = "always";
WorkingDirectory = "${cfg.package}/rest-api";
RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_UNIX"];
};
script = ''
@@ -142,11 +175,13 @@ in
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
serviceConfig = baseHardenedSystemdOptions // {
User = cfg.user;
Group = cfg.group;
Restart = "always";
WorkingDirectory = "${cfg.package}/web-app";
RestrictAddressFamilies = ["AF_INET" "AF_INET6"];
};
script = ''
@@ -169,6 +204,14 @@ in
extraPlugins = with pkgs.postgresql16Packages; [ pgjwt ];
};
systemd.services.postgresql = {
path = with pkgs; [
# Tar and gzip are needed for tar.gz exports
gnutar
gzip
];
};
services.nginx = {
enable = true;
recommendedProxySettings = true;
@@ -186,6 +229,11 @@ in
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(),autoplay=(),camera=(),cross-origin-isolated=(),display-capture=(),encrypted-media=(),fullscreen=(self),geolocation=(),gyroscope=(),keyboard-map=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(self),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),xr-spatial-tracking=(),clipboard-read=(self),clipboard-write=(self),gamepad=(),hid=(),idle-detection=(),interest-cohort=(),serial=(),unload=()" always;
map $http_cookie $auth_header {
default "";
"~*session_token=([^;]+)" "Bearer $1";
}
'';
virtualHosts = {
@@ -201,6 +249,13 @@ in
index = "index.html";
tryFiles = "$uri $uri/ $uri.html =404";
};
"/api/rpc/export_articles_zip" = {
proxyPass = "http://localhost:${toString cfg.apiPort}/rpc/export_articles_zip";
extraConfig = ''
default_type application/json;
proxy_set_header Authorization $auth_header;
'';
};
"/api/" = {
proxyPass = "http://localhost:${toString cfg.apiPort}/";
extraConfig = ''

View File

@@ -1,4 +1,6 @@
-- migrate:up
CREATE EXTENSION unaccent;
CREATE SCHEMA internal;
CREATE SCHEMA api;
@@ -27,6 +29,22 @@ GRANT USAGE ON SCHEMA internal TO authenticated_user;
ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
CREATE FUNCTION internal.generate_slug (TEXT)
RETURNS TEXT
AS $$
BEGIN
IF $1 ~ '[/\\.]' THEN
RAISE invalid_parameter_value
USING message = 'Title cannot contain "/", "\" or "."';
END IF;
RETURN REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(LOWER(TRIM(REGEXP_REPLACE(unaccent ($1), '\s+', '-', 'g'))), '[^\w-]', '', 'g'), '-+', '-', 'g'), '^-+', '', 'g'), '-+$', '', 'g');
END;
$$
LANGUAGE plpgsql
IMMUTABLE;
GRANT EXECUTE ON FUNCTION internal.generate_slug TO authenticated_user;
CREATE TABLE internal.user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
@@ -41,11 +59,12 @@ CREATE TABLE internal.website (
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
slug VARCHAR(50) GENERATED ALWAYS AS (internal.generate_slug (title)) STORED,
max_storage_size INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_storage_size') ::INT,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
UNIQUE (user_id, slug)
);
CREATE TABLE internal.media (
@@ -91,7 +110,7 @@ CREATE TABLE internal.docs_category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != '' AND category_name != 'Uncategorized'),
category_weight INT CHECK (category_weight >= 0) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
@@ -105,6 +124,7 @@ CREATE TABLE internal.article (
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
title VARCHAR(100) NOT NULL CHECK (TRIM(title) != ''),
slug VARCHAR(100) GENERATED ALWAYS AS (internal.generate_slug (title)) STORED,
meta_description VARCHAR(250) CHECK (TRIM(meta_description) != ''),
meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''),
cover_image UUID REFERENCES internal.media (id) ON DELETE SET NULL,
@@ -115,6 +135,7 @@ CREATE TABLE internal.article (
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
UNIQUE (website_id, slug),
UNIQUE (website_id, category, article_weight)
);
@@ -125,14 +146,6 @@ CREATE TABLE internal.footer (
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE TABLE internal.legal_information (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
main_content VARCHAR(200000) NOT NULL CHECK (TRIM(main_content) != ''),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE TABLE internal.collab (
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
@@ -146,8 +159,6 @@ CREATE TABLE internal.collab (
-- migrate:down
DROP TABLE internal.collab;
DROP TABLE internal.legal_information;
DROP TABLE internal.footer;
DROP TABLE internal.article;
@@ -168,6 +179,8 @@ DROP TABLE internal.user;
DROP SCHEMA api;
DROP FUNCTION internal.generate_slug;
DROP SCHEMA internal;
DROP ROLE anon;
@@ -180,3 +193,5 @@ DROP ROLE authenticator;
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
DROP EXTENSION unaccent;

View File

@@ -120,7 +120,7 @@ AS $$
DECLARE
_role NAME;
_user_id UUID;
_exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 86400;
_exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 43200;
BEGIN
SELECT
internal.user_role (login.username, login.pass) INTO _role;

View File

@@ -70,13 +70,6 @@ SELECT
FROM
internal.footer;
CREATE VIEW api.legal_information WITH ( security_invoker = ON
) AS
SELECT
*
FROM
internal.legal_information;
CREATE VIEW api.collab WITH ( security_invoker = ON
) AS
SELECT
@@ -137,7 +130,7 @@ GRANT SELECT ON api.account TO authenticated_user;
GRANT SELECT ON api.user TO authenticated_user;
GRANT SELECT, UPDATE (title, is_published), DELETE ON internal.website TO authenticated_user;
GRANT SELECT, UPDATE (title), DELETE ON internal.website TO authenticated_user;
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
@@ -165,10 +158,6 @@ GRANT SELECT, UPDATE (additional_text) ON internal.footer TO authenticated_user;
GRANT SELECT, UPDATE ON api.footer TO authenticated_user;
GRANT SELECT, INSERT (website_id, main_content), UPDATE (website_id, main_content), DELETE ON internal.legal_information TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.legal_information TO authenticated_user;
GRANT SELECT, INSERT (website_id, user_id, permission_level), UPDATE (permission_level), DELETE ON internal.collab TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
@@ -178,8 +167,6 @@ DROP FUNCTION api.create_website;
DROP VIEW api.collab;
DROP VIEW api.legal_information;
DROP VIEW api.footer;
DROP VIEW api.home;

View File

@@ -17,8 +17,6 @@ ALTER TABLE internal.docs_category ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.footer ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INT, collaborator_permission_level INT DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE, OUT has_access BOOLEAN)
@@ -155,22 +153,6 @@ CREATE POLICY update_footer ON internal.footer
FOR UPDATE
USING (internal.user_has_website_access (website_id, 20));
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));
CREATE POLICY view_collaborations ON internal.collab
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
@@ -232,14 +214,6 @@ DROP POLICY view_footer ON internal.footer;
DROP POLICY update_footer ON internal.footer;
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;
DROP POLICY view_collaborations ON internal.collab;
DROP POLICY insert_collaborations ON internal.collab;
@@ -268,7 +242,5 @@ ALTER TABLE internal.docs_category DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.footer DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.legal_information DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.collab DISABLE ROW LEVEL SECURITY;

View File

@@ -68,11 +68,6 @@ CREATE TRIGGER update_footer_last_modified
FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified ();
CREATE TRIGGER update_legal_information_last_modified
BEFORE INSERT OR UPDATE OR DELETE ON internal.legal_information
FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified ();
CREATE TRIGGER update_collab_last_modified
BEFORE INSERT OR UPDATE OR DELETE ON internal.collab
FOR EACH ROW
@@ -93,8 +88,6 @@ DROP TRIGGER update_docs_category_modified ON internal.docs_category;
DROP TRIGGER update_footer_last_modified ON internal.footer;
DROP TRIGGER update_legal_information_last_modified ON internal.legal_information;
DROP TRIGGER update_collab_last_modified ON internal.collab;
DROP FUNCTION internal.update_last_modified;

View File

@@ -1,5 +1,5 @@
-- migrate:up
CREATE DOMAIN "*/*" AS bytea;
CREATE DOMAIN "*/*" AS BYTEA;
CREATE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
AS $$

View File

@@ -127,11 +127,6 @@ CREATE TRIGGER track_changes_footer
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_legal_information
AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_collab
AFTER INSERT OR UPDATE OR DELETE ON internal.collab
FOR EACH ROW
@@ -154,8 +149,6 @@ DROP TRIGGER track_changes_docs_category ON internal.docs_category;
DROP TRIGGER track_changes_footer ON internal.footer;
DROP TRIGGER track_changes_legal_information ON internal.legal_information;
DROP TRIGGER track_changes_collab ON internal.collab;
DROP FUNCTION internal.track_changes;

View File

@@ -1,57 +0,0 @@
-- migrate:up
CREATE TABLE internal.domain_prefix (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
prefix VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(prefix) >= 3 AND prefix ~ '^[a-z]+(-[a-z]+)*$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE VIEW api.domain_prefix WITH ( security_invoker = ON
) AS
SELECT
*
FROM
internal.domain_prefix;
GRANT SELECT, INSERT (website_id, prefix), UPDATE (website_id, prefix), DELETE ON internal.domain_prefix TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.domain_prefix TO authenticated_user;
ALTER TABLE internal.domain_prefix ENABLE ROW LEVEL SECURITY;
CREATE POLICY view_domain_prefix ON internal.domain_prefix
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
CREATE POLICY update_domain_prefix ON internal.domain_prefix
FOR UPDATE
USING (internal.user_has_website_access (website_id, 30));
CREATE POLICY delete_domain_prefix ON internal.domain_prefix
FOR DELETE
USING (internal.user_has_website_access (website_id, 30));
CREATE POLICY insert_domain_prefix ON internal.domain_prefix
FOR INSERT
WITH CHECK (internal.user_has_website_access (website_id, 30));
CREATE TRIGGER update_domain_prefix_last_modified
BEFORE INSERT OR UPDATE OR DELETE ON internal.domain_prefix
FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified ();
CREATE TRIGGER track_changes_domain_prefix
AFTER INSERT OR UPDATE OR DELETE ON internal.domain_prefix
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-- migrate:down
DROP TRIGGER track_changes_domain_prefix ON internal.domain_prefix;
DROP TRIGGER update_domain_prefix_last_modified ON internal.domain_prefix;
DROP VIEW api.domain_prefix;
DROP TABLE internal.domain_prefix;

View File

@@ -12,14 +12,14 @@ CREATE FUNCTION api.user_websites_storage_size ()
AS $$
DECLARE
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
_tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'domain_prefix', 'footer', 'header', 'home', 'legal_information', 'media', 'settings', 'change_log'];
_tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'footer', 'header', 'home', 'media', 'settings', 'change_log'];
_query TEXT;
_union_queries TEXT := '';
BEGIN
FOR i IN 1..ARRAY_LENGTH(_tables, 1)
LOOP
_union_queries := _union_queries || FORMAT('
SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%s AS t WHERE t.website_id = w.id', _tables[i]);
SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%I AS t WHERE t.website_id = w.id', _tables[i]);
IF i < ARRAY_LENGTH(_tables, 1) THEN
_union_queries := _union_queries || ' UNION ALL ';
END IF;
@@ -42,9 +42,7 @@ BEGIN
w.user_id = $1
GROUP BY
w.id,
w.title
ORDER BY
storage_size_bytes DESC', _union_queries);
w.title', _union_queries);
RETURN QUERY EXECUTE _query
USING _user_id;
END;
@@ -69,14 +67,14 @@ DECLARE
WHERE
w.id = _website_id);
_max_storage_bytes BIGINT := _max_storage_mb::BIGINT * 1024 * 1024;
_tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'domain_prefix', 'footer', 'header', 'home', 'legal_information', 'media', 'settings', 'change_log'];
_tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'footer', 'header', 'home', 'media', 'settings', 'change_log'];
_union_queries TEXT := '';
_query TEXT;
BEGIN
FOR i IN 1..ARRAY_LENGTH(_tables, 1)
LOOP
_union_queries := _union_queries || FORMAT('
SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%s AS t WHERE t.website_id = $1', _tables[i]);
SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%I AS t WHERE t.website_id = $1', _tables[i]);
IF i < ARRAY_LENGTH(_tables, 1) THEN
_union_queries := _union_queries || ' UNION ALL ';
END IF;
@@ -111,11 +109,6 @@ CREATE TRIGGER _prevent_storage_excess_docs_category
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_domain_prefix
BEFORE INSERT OR UPDATE ON internal.domain_prefix
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_footer
BEFORE UPDATE ON internal.footer
FOR EACH ROW
@@ -131,11 +124,6 @@ CREATE TRIGGER _prevent_storage_excess_home
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_legal_information
BEFORE INSERT OR UPDATE ON internal.legal_information
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_media
BEFORE INSERT ON internal.media
FOR EACH ROW
@@ -161,16 +149,12 @@ DROP TRIGGER _prevent_storage_excess_collab ON internal.collab;
DROP TRIGGER _prevent_storage_excess_docs_category ON internal.docs_category;
DROP TRIGGER _prevent_storage_excess_domain_prefix ON internal.domain_prefix;
DROP TRIGGER _prevent_storage_excess_footer ON internal.footer;
DROP TRIGGER _prevent_storage_excess_header ON internal.header;
DROP TRIGGER _prevent_storage_excess_home ON internal.home;
DROP TRIGGER _prevent_storage_excess_legal_information ON internal.legal_information;
DROP TRIGGER _prevent_storage_excess_media ON internal.media;
DROP TRIGGER _prevent_storage_excess_settings ON internal.settings;

View File

@@ -4,51 +4,61 @@ CREATE FUNCTION internal.cleanup_filesystem ()
AS $$
DECLARE
_website_id UUID;
_domain_prefix VARCHAR(16);
_base_path CONSTANT TEXT := '/var/www/archtika-websites/';
_website_user_id UUID;
_website_slug TEXT;
_username TEXT;
_base_path CONSTANT TEXT := '/var/www/archtika-websites';
_preview_path TEXT;
_prod_path TEXT;
_article_slug TEXT;
BEGIN
IF TG_TABLE_NAME = 'website' THEN
_website_id := OLD.id;
_website_user_id = OLD.user_id;
_website_slug := OLD.slug;
ELSE
_website_id := OLD.website_id;
END IF;
SELECT
d.prefix INTO _domain_prefix
u.username INTO _username
FROM
internal.domain_prefix d
internal.user AS u
WHERE
d.website_id = _website_id;
_preview_path := _base_path || 'previews/' || _website_id;
_prod_path := _base_path || COALESCE(_domain_prefix, _website_id::TEXT);
u.id = _website_user_id;
_preview_path := _base_path || '/previews/' || _website_id;
_prod_path := _base_path || '/' || _username || '/' || _website_slug;
IF TG_TABLE_NAME = 'website' THEN
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf %s''', _preview_path);
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf %s''', _prod_path);
ELSE
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _preview_path);
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _prod_path);
EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''rm -rf %s''', _preview_path);
EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''rm -rf %s''', _prod_path);
ELSIF TG_TABLE_NAME = 'article' THEN
SELECT
a.slug INTO _article_slug
FROM
internal.article AS a
WHERE
a.id = OLD.id;
EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''rm -f %s/articles/%s.html''', _preview_path, _article_slug);
END IF;
RETURN OLD;
RETURN COALESCE(NEW, OLD);
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
CREATE TRIGGER _cleanup_filesystem_website
BEFORE DELETE ON internal.website
BEFORE UPDATE OF title OR DELETE ON internal.website
FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem ();
CREATE TRIGGER _cleanup_filesystem_legal_information
BEFORE DELETE ON internal.legal_information
CREATE TRIGGER _cleanup_filesystem_article
BEFORE UPDATE OF title OR DELETE ON internal.article
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 TRIGGER _cleanup_filesystem_article ON internal.article;
DROP FUNCTION internal.cleanup_filesystem;

View File

@@ -0,0 +1,43 @@
-- migrate:up
CREATE FUNCTION api.export_articles_zip (website_id UUID)
RETURNS "*/*"
AS $$
DECLARE
_has_access BOOLEAN;
_headers TEXT;
_article RECORD;
_markdown_dir TEXT := '/tmp/website-' || export_articles_zip.website_id;
BEGIN
_has_access = internal.user_has_website_access (export_articles_zip.website_id, 20);
SELECT
FORMAT('[{ "Content-Type": "application/gzip" },'
'{ "Content-Disposition": "attachment; filename=\"%s\"" }]', 'archtika-export-articles-' || export_articles_zip.website_id || '.tar.gz') INTO _headers;
PERFORM
SET_CONFIG('response.headers', _headers, TRUE);
EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''mkdir -p %s''', _markdown_dir || '/articles');
FOR _article IN (
SELECT
a.id,
a.website_id,
a.slug,
a.main_content
FROM
internal.article AS a
WHERE
a.website_id = export_articles_zip.website_id)
LOOP
EXECUTE FORMAT('COPY (SELECT %L) TO ''%s'' WITH (FORMAT CSV)', COALESCE(_article.main_content, 'No content yet'), _markdown_dir || '/articles/' || _article.slug || '.md');
EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''sed -i "s/^\"//;s/\"$//;s/\"\"/\"/g" %s''', _markdown_dir || '/articles/' || _article.slug || '.md');
END LOOP;
EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''tar -czf %s -C %s articles && rm %s''', _markdown_dir || '/export.tar.gz', _markdown_dir, _markdown_dir || '/articles/*.md');
RETURN PG_READ_BINARY_FILE(_markdown_dir || '/export.tar.gz');
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.export_articles_zip TO authenticated_user;
-- migrate:down
DROP FUNCTION api.export_articles_zip;

View File

@@ -0,0 +1,8 @@
-- migrate:up
ALTER TABLE internal.user
ADD CONSTRAINT username_not_blocked CHECK (LOWER(username) NOT IN ('admin', 'administrator', 'api', 'auth', 'blog', 'cdn', 'docs', 'help', 'login', 'logout', 'profile', 'register', 'settings', 'setup', 'signin', 'signup', 'support', 'test', 'www'));
-- migrate:down
ALTER TABLE internal.user
DROP CONSTRAINT username_not_blocked;

1060
web-app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"gents": "pg-to-ts generate -c postgres://postgres@localhost:15432/archtika -o src/lib/db-schema.ts -s internal"
"gents": "pg-to-ts generate -c postgres://postgres@localhost:15432/archtika -o src/lib/db-schema.ts -s internal --datesAsStrings"
},
"devDependencies": {
"@playwright/test": "1.47.0",
@@ -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

@@ -11,6 +11,7 @@ const config: PlaywrightTestConfig = {
},
testDir: "./tests",
testMatch: /(.+\.)?(test|spec)\.ts/,
workers: 1,
retries: 3,
// https://github.com/NixOS/nixpkgs/issues/288826
projects: [

View File

@@ -1,16 +1,35 @@
<script lang="ts">
const { date }: { date: Date } = $props();
const { date }: { date: string } = $props();
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
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],
["months", 60 * 60 * 24 * 30],
["weeks", 60 * 60 * 24 * 7],
["days", 60 * 60 * 24],
["hours", 60 * 60],
["minutes", 60],
["seconds", 1]
] as const;
for (const [rangeType, rangeVal] of ranges) {
if (rangeVal < Math.abs(secondsElapsed)) {
const delta = secondsElapsed / rangeVal;
return formatter.format(Math.round(delta), rangeType);
}
}
};
</script>
<time datetime={new Date(date).toLocaleString("sv").replace(" ", "T")}>
{new Date(date).toLocaleString("en-us", { ...options })}
<time datetime={dateObject.toLocaleString("sv").replace(" ", "T")}>
{calcTimeAgo(dateObject)}
</time>

View File

@@ -9,6 +9,14 @@
}: { children: Snippet; id: string; text: string; isWider?: boolean } = $props();
const modalId = `${id}-modal`;
$effect(() => {
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && window.location.hash === `#${modalId}`) {
window.location.hash = "!";
}
});
});
</script>
<a href={`#${modalId}`} role="button">{text}</a>

View File

@@ -25,15 +25,9 @@
previewElement.scrollTop = (textareaScrollTop.value / 100) * scrollHeight;
});
const tabs = [
"settings",
"articles",
"categories",
"collaborators",
"legal-information",
"publish",
"logs"
];
const tabs = ["settings", "articles", "categories", "collaborators", "publish", "logs"];
let iframeLoaded = $state(false);
</script>
<input type="checkbox" id="toggle-mobile-preview" hidden />
@@ -63,7 +57,15 @@
<div class="preview" bind:this={previewElement}>
{#if fullPreview}
<iframe src={previewContent.value} title="Preview"></iframe>
{#if !iframeLoaded}
<p>Loading preview...</p>
{/if}
<iframe
src={previewContent.value}
title="Preview"
onload={() => (iframeLoaded = true)}
style:display={iframeLoaded ? "block" : "none"}
></iframe>
{:else}
{@html md(
previewContent.value || "Write some markdown content to see a live preview here",

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
*
*/
@@ -17,15 +17,16 @@ export interface Article {
website_id: string;
user_id: string | null;
title: string;
slug: string | null;
meta_description: string | null;
meta_author: string | null;
cover_image: string | null;
publication_date: Date | null;
publication_date: string | null;
main_content: string | null;
category: string | null;
article_weight: number | null;
created_at: Date;
last_modified_at: Date;
created_at: string;
last_modified_at: string;
last_modified_by: string | null;
}
export interface ArticleInput {
@@ -33,15 +34,16 @@ export interface ArticleInput {
website_id: string;
user_id?: string | null;
title: string;
slug?: string | null;
meta_description?: string | null;
meta_author?: string | null;
cover_image?: string | null;
publication_date?: Date | null;
publication_date?: string | null;
main_content?: string | null;
category?: string | null;
article_weight?: number | null;
created_at?: Date;
last_modified_at?: Date;
created_at?: string;
last_modified_at?: string;
last_modified_by?: string | null;
}
const article = {
@@ -51,6 +53,7 @@ const article = {
"website_id",
"user_id",
"title",
"slug",
"meta_description",
"meta_author",
"cover_image",
@@ -81,7 +84,7 @@ export interface ChangeLog {
website_id: string | null;
user_id: string | null;
username: string;
tstamp: Date;
tstamp: string;
table_name: string;
operation: string;
old_value: any | null;
@@ -92,7 +95,7 @@ export interface ChangeLogInput {
website_id?: string | null;
user_id?: string | null;
username?: string;
tstamp?: Date;
tstamp?: string;
table_name: string;
operation: string;
old_value?: any | null;
@@ -126,16 +129,16 @@ export interface Collab {
website_id: string;
user_id: string;
permission_level: number;
added_at: Date;
last_modified_at: Date;
added_at: string;
last_modified_at: string;
last_modified_by: string | null;
}
export interface CollabInput {
website_id: string;
user_id: string;
permission_level?: number;
added_at?: Date;
last_modified_at?: Date;
added_at?: string;
last_modified_at?: string;
last_modified_by?: string | null;
}
const collab = {
@@ -166,8 +169,8 @@ export interface DocsCategory {
user_id: string | null;
category_name: string;
category_weight: number;
created_at: Date;
last_modified_at: Date;
created_at: string;
last_modified_at: string;
last_modified_by: string | null;
}
export interface DocsCategoryInput {
@@ -176,8 +179,8 @@ export interface DocsCategoryInput {
user_id?: string | null;
category_name: string;
category_weight: number;
created_at?: Date;
last_modified_at?: Date;
created_at?: string;
last_modified_at?: string;
last_modified_by?: string | null;
}
const docs_category = {
@@ -203,45 +206,17 @@ const docs_category = {
$input: null as unknown as DocsCategoryInput
} as const;
// Table domain_prefix
export interface DomainPrefix {
website_id: string;
prefix: string;
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface DomainPrefixInput {
website_id: string;
prefix: string;
created_at?: Date;
last_modified_at?: Date;
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;
additional_text: string;
last_modified_at: Date;
last_modified_at: string;
last_modified_by: string | null;
}
export interface FooterInput {
website_id: string;
additional_text: string;
last_modified_at?: Date;
last_modified_at?: string;
last_modified_by?: string | null;
}
const footer = {
@@ -263,7 +238,7 @@ export interface Header {
logo_type: string;
logo_text: string | null;
logo_image: string | null;
last_modified_at: Date;
last_modified_at: string;
last_modified_by: string | null;
}
export interface HeaderInput {
@@ -271,7 +246,7 @@ export interface HeaderInput {
logo_type?: string;
logo_text?: string | null;
logo_image?: string | null;
last_modified_at?: Date;
last_modified_at?: string;
last_modified_by?: string | null;
}
const header = {
@@ -300,14 +275,14 @@ export interface Home {
website_id: string;
main_content: string;
meta_description: string | null;
last_modified_at: Date;
last_modified_at: string;
last_modified_by: string | null;
}
export interface HomeInput {
website_id: string;
main_content: string;
meta_description?: string | null;
last_modified_at?: Date;
last_modified_at?: string;
last_modified_by?: string | null;
}
const home = {
@@ -329,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: Date;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface LegalInformationInput {
website_id: string;
main_content: string;
created_at?: Date;
last_modified_at?: Date;
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;
@@ -365,7 +312,7 @@ export interface Media {
blob: string;
mimetype: string;
original_name: string;
created_at: Date;
created_at: string;
}
export interface MediaInput {
id?: string;
@@ -374,7 +321,7 @@ export interface MediaInput {
blob: string;
mimetype: string;
original_name: string;
created_at?: Date;
created_at?: string;
}
const media = {
tableName: "media",
@@ -397,7 +344,7 @@ export interface Settings {
background_color_dark_theme: string;
background_color_light_theme: string;
favicon_image: string | null;
last_modified_at: Date;
last_modified_at: string;
last_modified_by: string | null;
}
export interface SettingsInput {
@@ -407,7 +354,7 @@ export interface SettingsInput {
background_color_dark_theme?: string;
background_color_light_theme?: string;
favicon_image?: string | null;
last_modified_at?: Date;
last_modified_at?: string;
last_modified_by?: string | null;
}
const settings = {
@@ -440,7 +387,7 @@ export interface User {
password_hash: string;
user_role: string;
max_number_websites: number;
created_at: Date;
created_at: string;
}
export interface UserInput {
id?: string;
@@ -448,7 +395,7 @@ export interface UserInput {
password_hash: string;
user_role?: string;
max_number_websites?: number;
created_at?: Date;
created_at?: string;
}
const user = {
tableName: "user",
@@ -466,10 +413,10 @@ export interface Website {
user_id: string;
content_type: string;
title: string;
slug: string | null;
max_storage_size: number;
is_published: boolean;
created_at: Date;
last_modified_at: Date;
created_at: string;
last_modified_at: string;
last_modified_by: string | null;
}
export interface WebsiteInput {
@@ -477,10 +424,10 @@ export interface WebsiteInput {
user_id?: string;
content_type: string;
title: string;
slug?: string | null;
max_storage_size?: number;
is_published?: boolean;
created_at?: Date;
last_modified_at?: Date;
created_at?: string;
last_modified_at?: string;
last_modified_by?: string | null;
}
const website = {
@@ -490,8 +437,8 @@ const website = {
"user_id",
"content_type",
"title",
"slug",
"max_storage_size",
"is_published",
"created_at",
"last_modified_at",
"last_modified_by"
@@ -523,10 +470,6 @@ export interface TableTypes {
select: DocsCategory;
input: DocsCategoryInput;
};
domain_prefix: {
select: DomainPrefix;
input: DomainPrefixInput;
};
footer: {
select: Footer;
input: FooterInput;
@@ -539,10 +482,6 @@ export interface TableTypes {
select: Home;
input: HomeInput;
};
legal_information: {
select: LegalInformation;
input: LegalInformationInput;
};
media: {
select: Media;
input: MediaInput;
@@ -566,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,11 +1,12 @@
<script lang="ts">
import { slugify, type WebsiteOverview } from "../../utils";
import { type WebsiteOverview } from "../utils";
const {
websiteOverview,
nestingLevel,
apiUrl,
title,
slug,
metaDescription,
websiteUrl
}: {
@@ -13,6 +14,7 @@
nestingLevel: number;
apiUrl: string;
title: string;
slug?: string;
metaDescription?: string | null;
websiteUrl: string;
} = $props();
@@ -20,7 +22,7 @@
const constructedTitle =
websiteOverview.title === title ? title : `${websiteOverview.title} | ${title}`;
let ogUrl = `${websiteUrl.replace(/\/$/, "")}${nestingLevel === 0 ? (websiteOverview.title === title ? "" : `/${slugify(title)}`) : `/articles/${slugify(title)}`}`;
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, slugify, type WebsiteOverview } from "$lib/utils";
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>
@@ -62,7 +61,7 @@
{/if}
<p>
<strong>
<a href="./articles/{slugify(article.title)}">{article.title}</a>
<a href="./articles/{article.slug}">{article.title}</a>
</strong>
</p>
{#if article.meta_description}
@@ -76,4 +75,4 @@
</div>
</main>
<Footer {websiteOverview} isIndexPage={true} />
<Footer {websiteOverview} />

View File

@@ -1,19 +1,17 @@
<script lang="ts">
import { type WebsiteOverview, slugify } 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(
@@ -61,9 +59,9 @@
<li>
<strong>{key}</strong>
<ul>
{#each categorizedArticles[key] as { title }}
{#each categorizedArticles[key] as { title, slug }}
<li>
<a href="{isIndexPage ? './articles' : '.'}/{slugify(title)}">{title}</a>
<a href="{isIndexPage ? './articles' : '.'}/{slug}">{title}</a>
</li>
{/each}
</ul>
@@ -72,22 +70,19 @@
</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}
</svelte:element>
<label style="margin-inline-start: auto;" for="toggle-theme">
<label for="toggle-theme">
<input type="checkbox" id="toggle-theme" hidden />
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,50 +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}
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,42 +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}
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";
@@ -26,7 +25,7 @@ export const ALLOWED_MIME_TYPES = [
"image/svg+xml"
];
export const slugify = (string: string) => {
const slugify = (string: string) => {
return string
.toString()
.normalize("NFKD") // Normalize Unicode characters
@@ -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

@@ -18,7 +18,7 @@ export const actions: Actions = {
return response;
}
cookies.set("session_token", response.data.token, { path: "/", maxAge: 86400 });
cookies.set("session_token", response.data.token, { path: "/", maxAge: 43200 });
return response;
}
};

View File

@@ -76,19 +76,8 @@ export const actions: Actions = {
const data = await request.formData();
const id = data.get("id");
const deleteWebsite = await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${id}`,
"DELETE",
{
successMessage: "Successfully deleted website"
}
);
if (!deleteWebsite.success) {
return deleteWebsite;
}
return deleteWebsite;
return await apiRequest(fetch, `${API_BASE_PREFIX}/website?id=eq.${id}`, "DELETE", {
successMessage: "Successfully deleted website"
});
}
};

View File

@@ -77,23 +77,18 @@
</details>
<ul class="website-grid unpadded">
{#each data.websites as { id, user_id, content_type, title, created_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 at:</strong>
<DateTime date={created_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

@@ -4,7 +4,7 @@ import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
export const load: PageServerLoad = async ({ fetch, locals }) => {
const storageSizes = await apiRequest(
fetch,
`${API_BASE_PREFIX}/rpc/user_websites_storage_size`,
`${API_BASE_PREFIX}/rpc/user_websites_storage_size?order=max_storage_bytes.desc`,
"GET",
{
returnData: true

View File

@@ -10,7 +10,7 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
const usersWithWebsites: (User & { website: Website[] })[] = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/user?select=*,website!user_id(*)&order=created_at&limit=${PAGINATION_MAX_ITEMS}&offset=${resultOffset}`,
`${API_BASE_PREFIX}/user?select=*,website!user_id(*)&order=created_at&website.order=created_at.desc&limit=${PAGINATION_MAX_ITEMS}&offset=${resultOffset}`,
"GET",
{
returnData: true
@@ -61,7 +61,7 @@ export const actions: Actions = {
body: {
max_storage_size: data.get("storage-size")
},
successMessage: "Successfully updated user website storage size"
successMessage: "Successfully updated website storage"
}
);
},

View File

@@ -1,6 +1,7 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { apiRequest } from "$lib/server/utils";
import { parse } from "node:path";
import type { Article, DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch, url, parent, locals }) => {
@@ -58,6 +59,7 @@ export const load: PageServerLoad = async ({ params, fetch, url, parent, locals
website,
home,
permissionLevel,
API_BASE_PREFIX,
user: locals.user
};
};
@@ -74,6 +76,25 @@ export const actions: Actions = {
successMessage: "Successfully created article"
});
},
importArticles: async ({ request, fetch, params }) => {
const data = await request.formData();
const files = data.getAll("import-articles") as File[];
const articles = await Promise.all(
files.map(async (file) => {
return {
website_id: params.websiteId,
title: parse(file.name).name,
main_content: await file.text()
};
})
);
return await apiRequest(fetch, `${API_BASE_PREFIX}/article`, "POST", {
body: articles,
successMessage: "Successfully imported articles"
});
},
deleteArticle: async ({ request, fetch }) => {
const data = await request.formData();

View File

@@ -31,18 +31,37 @@
<a href="#create-article">Create article</a>
</h2>
<Modal id="create-article" text="Create article">
<h3>Create article</h3>
<form method="POST" action="?/createArticle" use:enhance={enhanceForm({ closeModal: true })}>
<label>
Title:
<input type="text" name="title" pattern="\S(.*\S)?" maxlength="100" required />
</label>
<button type="submit" disabled={data.permissionLevel === 10}>Create article</button>
</form>
</Modal>
<div class="multi-wrapper">
<Modal id="create-article" text="Create article">
<h3>Create article</h3>
<form
method="POST"
action="?/createArticle"
use:enhance={enhanceForm({ closeModal: true })}
>
<label>
Title:
<input type="text" name="title" pattern="\S(.*\S)?" maxlength="100" required />
</label>
<button type="submit" disabled={data.permissionLevel === 10}>Create article</button>
</form>
</Modal>
<Modal id="import-articles" text="Import articles">
<h3>Import articles</h3>
<form
method="POST"
action="?/importArticles"
enctype="multipart/form-data"
use:enhance={enhanceForm({ closeModal: true })}
>
<label>
Markdown files:
<input type="file" name="import-articles" accept=".md" multiple required />
</label>
<button type="submit" disabled={data.permissionLevel === 10}>Import articles</button>
</form>
</Modal>
</div>
</section>
{#if data.totalArticleCount > 0}
@@ -51,6 +70,11 @@
<a href="#all-articles">All articles</a>
</h2>
<a
class="export-anchor"
href={`${data.API_BASE_PREFIX}/rpc/export_articles_zip?website_id=${data.website.id}`}
download>Export articles</a
>
<details>
<summary>Search & Filter</summary>
<form method="GET">
@@ -92,7 +116,6 @@
fill="currentColor"
width="16"
height="16"
style="vertical-align: middle"
>
<path
fill-rule="evenodd"
@@ -160,4 +183,15 @@
gap: var(--space-s);
align-items: center;
}
.multi-wrapper {
display: flex;
gap: var(--space-s);
flex-wrap: wrap;
align-items: start;
}
.export-anchor {
max-inline-size: fit-content;
}
</style>

View File

@@ -36,7 +36,13 @@
<form method="POST" action="?/createCategory" use:enhance={enhanceForm({ closeModal: true })}>
<label>
Name:
<input type="text" name="category-name" maxlength="50" required />
<input
type="text"
name="category-name"
maxlength="50"
pattern="^(?!Uncategorized$).+$"
required
/>
</label>
<label>
@@ -80,6 +86,7 @@
name="category-name"
value={category_name}
maxlength="50"
pattern="^(?!Uncategorized$).+$"
required
/>
</label>

View File

@@ -1,78 +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 }) => {
const deleteLegalInformation = await apiRequest(
fetch,
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
"DELETE",
{
successMessage: "Successfully deleted legal information"
}
);
if (!deleteLegalInformation.success) {
return deleteLegalInformation;
}
return deleteLegalInformation;
},
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>
@@ -106,7 +107,7 @@
<th>User</th>
<th>Resource</th>
<th>Operation</th>
<th>Date & Time</th>
<th>Time</th>
<th>Changes</th>
</tr>
</thead>
@@ -140,21 +141,20 @@
<button type="submit">Compute diff</button>
</form>
{#if form?.logId === id && form?.currentDiff}
<pre style="white-space: pre-wrap">{@html DOMPurify.sanitize(
form.currentDiff,
{ ALLOWED_TAGS: ["ins", "del"] }
)}</pre>
<pre>{@html DOMPurify.sanitize(form.currentDiff, {
ALLOWED_TAGS: ["ins", "del"]
})}</pre>
{/if}
{/if}
{#if new_value && !old_value}
<h4>New value</h4>
<pre style="white-space: pre-wrap">{DOMPurify.sanitize(newValue)}</pre>
<pre>{DOMPurify.sanitize(newValue)}</pre>
{/if}
{#if old_value && !new_value}
<h4>Old value</h4>
<pre style="white-space: pre-wrap">{DOMPurify.sanitize(oldValue)}</pre>
<pre>{DOMPurify.sanitize(oldValue)}</pre>
{/if}
</Modal>
</td>
@@ -164,8 +164,14 @@
</table>
</div>
<Pagination
commonFilters={["user", "resource", "operation"]}
commonFilters={["user", "resource", "operation", "since"]}
resultCount={data.resultChangeLogCount}
/>
</section>
</WebsiteEditor>
<style>
pre {
white-space: pre-wrap;
}
</style>

View File

@@ -1,147 +1,101 @@
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 { type WebsiteOverview, hexToHSL, slugify } from "$lib/utils";
import { mkdir, readFile, rename, writeFile, chmod, readdir } from "node:fs/promises";
import Index from "$lib/templates/Index.svelte";
import Article from "$lib/templates/Article.svelte";
import { type WebsiteOverview, hexToHSL } from "$lib/utils";
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=*,user!user_id(*),settings(*),header(*),home(*),footer(*),article(*,docs_category(*))`;
};
export const load: PageServerLoad = async ({ params, fetch, parent }) => {
const websiteOverview: WebsiteOverview = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
)
await apiRequest(fetch, getOverviewFetchUrl(params.websiteId), "GET", {
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
})
).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,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
)
).data;
await generateStaticFiles(websiteOverview, false);
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`,
"PATCH",
{
body: {
is_published: true
},
successMessage: "Successfully published website"
}
);
},
createUpdateCustomDomainPrefix: async ({ request, fetch, params }) => {
const data = await request.formData();
const oldDomainPrefix = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/domain_prefix?website_id=eq.${params.websiteId}`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
)
).data;
const newDomainPrefix = await apiRequest(fetch, `${API_BASE_PREFIX}/domain_prefix`, "POST", {
headers: {
Prefer: "resolution=merge-duplicates",
Accept: "application/vnd.pgrst.object+json"
},
body: {
website_id: params.websiteId,
prefix: data.get("domain-prefix")
},
successMessage: "Successfully created/updated domain prefix"
});
if (!newDomainPrefix.success) {
return newDomainPrefix;
}
await rename(
join(
"/",
"var",
"www",
"archtika-websites",
oldDomainPrefix?.prefix ? oldDomainPrefix.prefix : params.websiteId
),
join("/", "var", "www", "archtika-websites", data.get("domain-prefix") as string)
);
return newDomainPrefix;
},
deleteCustomDomainPrefix: async ({ fetch, params }) => {
const customPrefix = await apiRequest(
fetch,
`${API_BASE_PREFIX}/domain_prefix?website_id=eq.${params.websiteId}`,
"DELETE",
{
await apiRequest(fetch, getOverviewFetchUrl(params.websiteId), "GET", {
headers: {
Prefer: "return=representation",
Accept: "application/vnd.pgrst.object+json"
},
successMessage: "Successfully deleted domain prefix",
returnData: true
}
);
})
).data;
if (!customPrefix.success) {
return customPrefix;
let permissionLevel = 40;
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 rename(
join("/", "var", "www", "archtika-websites", customPrefix.data.prefix),
join("/", "var", "www", "archtika-websites", params.websiteId)
);
if (permissionLevel < 30) {
return { success: false, message: "Insufficient permissions" };
}
return customPrefix;
await generateStaticFiles(websiteOverview, false, fetch);
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"
@@ -151,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 `
@@ -172,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
}
});
@@ -185,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,
@@ -211,23 +197,7 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
}
});
await writeFile(
join(uploadDir, "articles", `${slugify(article.title)}.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));
await writeFile(join(uploadDir, "articles", `${article.slug}.html`), fileContents(head, body));
}
const variableStyles = await readFile(`${process.cwd()}/template-styles/variables.css`, {
@@ -293,20 +263,21 @@ 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 };
};
const setPermissions = async (dir: string) => {
await chmod(dir, 0o777);
const mode = dev ? 0o777 : process.env.ORIGIN ? 0o770 : 0o777;
await chmod(dir, mode);
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);
await chmod(fullPath, mode);
}
}
};

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="^[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

@@ -88,11 +88,16 @@ summary {
background-color: var(--bg-secondary);
}
summary:has(svg),
label:has(svg) {
display: inline-grid;
place-content: center;
}
label[for="toggle-theme"] {
margin-inline-start: auto;
}
label[for="toggle-theme"] svg:first-of-type {
display: var(--display-light);
}
@@ -113,6 +118,7 @@ label[for="toggle-theme"] svg:last-of-type {
}
button:disabled {
user-select: none;
pointer-events: none;
opacity: 0.5;
z-index: -10;
@@ -272,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,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import {
userOwner,
authenticate,
@@ -7,6 +8,8 @@ import {
collabTestingWebsite
} from "./shared";
const genArticleName = () => randomBytes(12).toString("hex");
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
@@ -21,7 +24,7 @@ test.describe("Website owner", () => {
test(`Create article`, async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click();
await page.locator("#create-article-modal").getByLabel("Title:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
await page.locator("#create-article-modal").getByLabel("Title:").fill(genArticleName());
await page
.locator("#create-article-modal")
.getByRole("button", { name: "Create article" })
@@ -34,7 +37,7 @@ test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click();
await page.locator("#create-article-modal").getByLabel("Title:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
await page.locator("#create-article-modal").getByLabel("Title:").fill(genArticleName());
await page
.locator("#create-article-modal")
.getByRole("button", { name: "Create article" })
@@ -81,7 +84,7 @@ for (const permissionLevel of permissionLevels) {
test(`Create article`, async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click();
await page.locator("#create-article-modal").getByLabel("Title:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
await page.locator("#create-article-modal").getByLabel("Title:").fill(genArticleName());
await page
.locator("#create-article-modal")
.getByRole("button", { name: "Create article" })

View File

@@ -6,6 +6,9 @@ import {
collabUsers,
collabTestingWebsite
} from "./shared";
import { randomBytes } from "node:crypto";
const genWebsiteName = () => randomBytes(12).toString("hex");
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
@@ -16,7 +19,7 @@ test.describe("Website owner", () => {
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type:").selectOption("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").click();
await page.locator("#create-website-modal").getByLabel("Title:").fill("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").fill(genWebsiteName());
await page
.locator("#create-website-modal")
.getByRole("button", { name: "Create website" })
@@ -32,7 +35,7 @@ test.describe("Website owner", () => {
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type:").selectOption("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").click();
await page.locator("#create-website-modal").getByLabel("Title:").fill("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").fill(genWebsiteName());
await page
.locator("#create-website-modal")
.getByRole("button", { name: "Create website" })
@@ -48,7 +51,7 @@ test.describe("Website owner", () => {
.click();
const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).getByLabel("Title:").click();
await page.locator(`#${modalName}`).getByLabel("Title:").fill(`${"Blog"} updated`);
await page.locator(`#${modalName}`).getByLabel("Title:").fill(genWebsiteName());
await page.getByRole("button", { name: "Update website" }).click();
await expect(page.getByText("Successfully updated website")).toBeVisible();
});

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

@@ -47,7 +47,7 @@ test(`Update user website storage limit`, async ({ page }) => {
.locator("details")
.getByRole("button", { name: "Update storage limit" })
.click();
await expect(page.getByText("Successfully updated user website storage size")).toBeVisible();
await expect(page.getByText("Successfully updated website storage")).toBeVisible();
});
test(`Delete user`, async ({ page }) => {

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();
}
});
});
}