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"; 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"; services.getty.autologinUser = "dev";
system.stateVersion = "24.05"; system.stateVersion = "24.05";

View File

@@ -9,6 +9,29 @@ with lib;
let let
cfg = config.services.archtika; 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 in
{ {
options.services.archtika = { options.services.archtika = {
@@ -105,9 +128,17 @@ in
group = cfg.group; 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 = { systemd.services.archtika-api = {
description = "archtika API service"; description = "archtika API service";
@@ -117,11 +148,13 @@ in
"postgresql.service" "postgresql.service"
]; ];
serviceConfig = { serviceConfig = baseHardenedSystemdOptions // {
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
Restart = "always"; Restart = "always";
WorkingDirectory = "${cfg.package}/rest-api"; WorkingDirectory = "${cfg.package}/rest-api";
RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_UNIX"];
}; };
script = '' script = ''
@@ -142,11 +175,13 @@ in
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]; after = [ "network.target" ];
serviceConfig = { serviceConfig = baseHardenedSystemdOptions // {
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
Restart = "always"; Restart = "always";
WorkingDirectory = "${cfg.package}/web-app"; WorkingDirectory = "${cfg.package}/web-app";
RestrictAddressFamilies = ["AF_INET" "AF_INET6"];
}; };
script = '' script = ''
@@ -169,6 +204,14 @@ in
extraPlugins = with pkgs.postgresql16Packages; [ pgjwt ]; extraPlugins = with pkgs.postgresql16Packages; [ pgjwt ];
}; };
systemd.services.postgresql = {
path = with pkgs; [
# Tar and gzip are needed for tar.gz exports
gnutar
gzip
];
};
services.nginx = { services.nginx = {
enable = true; enable = true;
recommendedProxySettings = true; recommendedProxySettings = true;
@@ -186,6 +229,11 @@ in
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" 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; 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 = { virtualHosts = {
@@ -201,6 +249,13 @@ in
index = "index.html"; index = "index.html";
tryFiles = "$uri $uri/ $uri.html =404"; 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/" = { "/api/" = {
proxyPass = "http://localhost:${toString cfg.apiPort}/"; proxyPass = "http://localhost:${toString cfg.apiPort}/";
extraConfig = '' extraConfig = ''

View File

@@ -1,4 +1,6 @@
-- migrate:up -- migrate:up
CREATE EXTENSION unaccent;
CREATE SCHEMA internal; CREATE SCHEMA internal;
CREATE SCHEMA api; CREATE SCHEMA api;
@@ -27,6 +29,22 @@ GRANT USAGE ON SCHEMA internal TO authenticated_user;
ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; 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 ( CREATE TABLE internal.user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (), id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'), 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, 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, content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''), 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, 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_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 ( CREATE TABLE internal.media (
@@ -91,7 +110,7 @@ CREATE TABLE internal.docs_category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (), id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL, 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, 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, category_weight INT CHECK (category_weight >= 0) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(), created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_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, 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, 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) != ''), 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_description VARCHAR(250) CHECK (TRIM(meta_description) != ''),
meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''), meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''),
cover_image UUID REFERENCES internal.media (id) ON DELETE SET NULL, 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_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 (website_id, slug),
UNIQUE (website_id, category, article_weight) 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 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 ( CREATE TABLE internal.collab (
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE, website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE, user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
@@ -146,8 +159,6 @@ CREATE TABLE internal.collab (
-- migrate:down -- migrate:down
DROP TABLE internal.collab; DROP TABLE internal.collab;
DROP TABLE internal.legal_information;
DROP TABLE internal.footer; DROP TABLE internal.footer;
DROP TABLE internal.article; DROP TABLE internal.article;
@@ -168,6 +179,8 @@ DROP TABLE internal.user;
DROP SCHEMA api; DROP SCHEMA api;
DROP FUNCTION internal.generate_slug;
DROP SCHEMA internal; DROP SCHEMA internal;
DROP ROLE anon; DROP ROLE anon;
@@ -180,3 +193,5 @@ DROP ROLE authenticator;
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC; ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
DROP EXTENSION unaccent;

View File

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

View File

@@ -70,13 +70,6 @@ SELECT
FROM FROM
internal.footer; 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 CREATE VIEW api.collab WITH ( security_invoker = ON
) AS ) AS
SELECT SELECT
@@ -137,7 +130,7 @@ GRANT SELECT ON api.account TO authenticated_user;
GRANT SELECT ON api.user 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; 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, 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 (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; 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.collab;
DROP VIEW api.legal_information;
DROP VIEW api.footer; DROP VIEW api.footer;
DROP VIEW api.home; 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.footer ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.collab 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) 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 FOR UPDATE
USING (internal.user_has_website_access (website_id, 20)); 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 CREATE POLICY view_collaborations ON internal.collab
FOR SELECT FOR SELECT
USING (internal.user_has_website_access (website_id, 10)); 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 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 view_collaborations ON internal.collab;
DROP POLICY insert_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.footer DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.legal_information DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.collab 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 FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified (); 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 CREATE TRIGGER update_collab_last_modified
BEFORE INSERT OR UPDATE OR DELETE ON internal.collab BEFORE INSERT OR UPDATE OR DELETE ON internal.collab
FOR EACH ROW 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_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 TRIGGER update_collab_last_modified ON internal.collab;
DROP FUNCTION internal.update_last_modified; DROP FUNCTION internal.update_last_modified;

View File

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

View File

@@ -127,11 +127,6 @@ CREATE TRIGGER track_changes_footer
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.track_changes (); 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 CREATE TRIGGER track_changes_collab
AFTER INSERT OR UPDATE OR DELETE ON internal.collab AFTER INSERT OR UPDATE OR DELETE ON internal.collab
FOR EACH ROW 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_footer ON internal.footer;
DROP TRIGGER track_changes_legal_information ON internal.legal_information;
DROP TRIGGER track_changes_collab ON internal.collab; DROP TRIGGER track_changes_collab ON internal.collab;
DROP FUNCTION internal.track_changes; 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 $$ AS $$
DECLARE DECLARE
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID; _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; _query TEXT;
_union_queries TEXT := ''; _union_queries TEXT := '';
BEGIN BEGIN
FOR i IN 1..ARRAY_LENGTH(_tables, 1) FOR i IN 1..ARRAY_LENGTH(_tables, 1)
LOOP LOOP
_union_queries := _union_queries || FORMAT(' _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 IF i < ARRAY_LENGTH(_tables, 1) THEN
_union_queries := _union_queries || ' UNION ALL '; _union_queries := _union_queries || ' UNION ALL ';
END IF; END IF;
@@ -42,9 +42,7 @@ BEGIN
w.user_id = $1 w.user_id = $1
GROUP BY GROUP BY
w.id, w.id,
w.title w.title', _union_queries);
ORDER BY
storage_size_bytes DESC', _union_queries);
RETURN QUERY EXECUTE _query RETURN QUERY EXECUTE _query
USING _user_id; USING _user_id;
END; END;
@@ -69,14 +67,14 @@ DECLARE
WHERE WHERE
w.id = _website_id); w.id = _website_id);
_max_storage_bytes BIGINT := _max_storage_mb::BIGINT * 1024 * 1024; _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 := ''; _union_queries TEXT := '';
_query TEXT; _query TEXT;
BEGIN BEGIN
FOR i IN 1..ARRAY_LENGTH(_tables, 1) FOR i IN 1..ARRAY_LENGTH(_tables, 1)
LOOP LOOP
_union_queries := _union_queries || FORMAT(' _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 IF i < ARRAY_LENGTH(_tables, 1) THEN
_union_queries := _union_queries || ' UNION ALL '; _union_queries := _union_queries || ' UNION ALL ';
END IF; END IF;
@@ -111,11 +109,6 @@ CREATE TRIGGER _prevent_storage_excess_docs_category
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess (); 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 CREATE TRIGGER _prevent_storage_excess_footer
BEFORE UPDATE ON internal.footer BEFORE UPDATE ON internal.footer
FOR EACH ROW FOR EACH ROW
@@ -131,11 +124,6 @@ CREATE TRIGGER _prevent_storage_excess_home
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess (); 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 CREATE TRIGGER _prevent_storage_excess_media
BEFORE INSERT ON internal.media BEFORE INSERT ON internal.media
FOR EACH ROW 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_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_footer ON internal.footer;
DROP TRIGGER _prevent_storage_excess_header ON internal.header; DROP TRIGGER _prevent_storage_excess_header ON internal.header;
DROP TRIGGER _prevent_storage_excess_home ON internal.home; 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_media ON internal.media;
DROP TRIGGER _prevent_storage_excess_settings ON internal.settings; DROP TRIGGER _prevent_storage_excess_settings ON internal.settings;

View File

@@ -4,51 +4,61 @@ CREATE FUNCTION internal.cleanup_filesystem ()
AS $$ AS $$
DECLARE DECLARE
_website_id UUID; _website_id UUID;
_domain_prefix VARCHAR(16); _website_user_id UUID;
_base_path CONSTANT TEXT := '/var/www/archtika-websites/'; _website_slug TEXT;
_username TEXT;
_base_path CONSTANT TEXT := '/var/www/archtika-websites';
_preview_path TEXT; _preview_path TEXT;
_prod_path TEXT; _prod_path TEXT;
_article_slug TEXT;
BEGIN BEGIN
IF TG_TABLE_NAME = 'website' THEN IF TG_TABLE_NAME = 'website' THEN
_website_id := OLD.id; _website_id := OLD.id;
_website_user_id = OLD.user_id;
_website_slug := OLD.slug;
ELSE ELSE
_website_id := OLD.website_id; _website_id := OLD.website_id;
END IF; END IF;
SELECT SELECT
d.prefix INTO _domain_prefix u.username INTO _username
FROM FROM
internal.domain_prefix d internal.user AS u
WHERE WHERE
d.website_id = _website_id; u.id = _website_user_id;
_preview_path := _base_path || 'previews/' || _website_id; _preview_path := _base_path || '/previews/' || _website_id;
_prod_path := _base_path || COALESCE(_domain_prefix, _website_id::TEXT); _prod_path := _base_path || '/' || _username || '/' || _website_slug;
IF TG_TABLE_NAME = 'website' THEN IF TG_TABLE_NAME = 'website' THEN
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf %s''', _preview_path); EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''rm -rf %s''', _preview_path);
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf %s''', _prod_path); EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''rm -rf %s''', _prod_path);
ELSE ELSIF TG_TABLE_NAME = 'article' THEN
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _preview_path); SELECT
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _prod_path); 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; END IF;
RETURN OLD; RETURN COALESCE(NEW, OLD);
END; END;
$$ $$
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER; SECURITY DEFINER;
CREATE TRIGGER _cleanup_filesystem_website CREATE TRIGGER _cleanup_filesystem_website
BEFORE DELETE ON internal.website BEFORE UPDATE OF title OR DELETE ON internal.website
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem (); EXECUTE FUNCTION internal.cleanup_filesystem ();
CREATE TRIGGER _cleanup_filesystem_legal_information CREATE TRIGGER _cleanup_filesystem_article
BEFORE DELETE ON internal.legal_information BEFORE UPDATE OF title OR DELETE ON internal.article
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem (); EXECUTE FUNCTION internal.cleanup_filesystem ();
-- migrate:down -- migrate:down
DROP TRIGGER _cleanup_filesystem_website ON internal.website; 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; 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", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "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": { "devDependencies": {
"@playwright/test": "1.47.0", "@playwright/test": "1.47.0",
@@ -24,7 +24,7 @@
"@types/eslint__js": "8.42.3", "@types/eslint__js": "8.42.3",
"@types/eslint-config-prettier": "6.11.3", "@types/eslint-config-prettier": "6.11.3",
"@types/node": "22.5.5", "@types/node": "22.5.5",
"eslint": "9.10.0", "eslint": "9.15.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "2.44.0", "eslint-plugin-svelte": "2.44.0",
"globals": "15.9.0", "globals": "15.9.0",
@@ -37,12 +37,15 @@
"typescript-eslint": "8.6.0", "typescript-eslint": "8.6.0",
"vite": "5.4.6" "vite": "5.4.6"
}, },
"type": "module",
"dependencies": { "dependencies": {
"diff-match-patch": "1.0.5", "diff-match-patch": "1.0.5",
"highlight.js": "11.10.0", "highlight.js": "11.10.0",
"isomorphic-dompurify": "2.15.0", "isomorphic-dompurify": "2.15.0",
"marked": "14.1.2", "marked": "14.1.2",
"marked-highlight": "2.1.4" "marked-highlight": "2.1.4"
} },
"overrides": {
"cookie": "0.7.0"
},
"type": "module"
} }

View File

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

View File

@@ -1,16 +1,35 @@
<script lang="ts"> <script lang="ts">
const { date }: { date: Date } = $props(); const { date }: { date: string } = $props();
const options: Intl.DateTimeFormatOptions = { const dateObject = new Date(date);
year: "numeric",
month: "2-digit", const calcTimeAgo = (date: Date) => {
day: "2-digit", const secondsElapsed = (date.getTime() - Date.now()) / 1000;
hour: "2-digit",
minute: "2-digit", if (Math.abs(secondsElapsed) < 1) {
second: "2-digit" 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> </script>
<time datetime={new Date(date).toLocaleString("sv").replace(" ", "T")}> <time datetime={dateObject.toLocaleString("sv").replace(" ", "T")}>
{new Date(date).toLocaleString("en-us", { ...options })} {calcTimeAgo(dateObject)}
</time> </time>

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
* AUTO-GENERATED FILE - DO NOT EDIT! * AUTO-GENERATED FILE - DO NOT EDIT!
* *
* This file was automatically generated by pg-to-ts v.4.1.1 * 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; website_id: string;
user_id: string | null; user_id: string | null;
title: string; title: string;
slug: string | null;
meta_description: string | null; meta_description: string | null;
meta_author: string | null; meta_author: string | null;
cover_image: string | null; cover_image: string | null;
publication_date: Date | null; publication_date: string | null;
main_content: string | null; main_content: string | null;
category: string | null; category: string | null;
article_weight: number | null; article_weight: number | null;
created_at: Date; created_at: string;
last_modified_at: Date; last_modified_at: string;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface ArticleInput { export interface ArticleInput {
@@ -33,15 +34,16 @@ export interface ArticleInput {
website_id: string; website_id: string;
user_id?: string | null; user_id?: string | null;
title: string; title: string;
slug?: string | null;
meta_description?: string | null; meta_description?: string | null;
meta_author?: string | null; meta_author?: string | null;
cover_image?: string | null; cover_image?: string | null;
publication_date?: Date | null; publication_date?: string | null;
main_content?: string | null; main_content?: string | null;
category?: string | null; category?: string | null;
article_weight?: number | null; article_weight?: number | null;
created_at?: Date; created_at?: string;
last_modified_at?: Date; last_modified_at?: string;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const article = { const article = {
@@ -51,6 +53,7 @@ const article = {
"website_id", "website_id",
"user_id", "user_id",
"title", "title",
"slug",
"meta_description", "meta_description",
"meta_author", "meta_author",
"cover_image", "cover_image",
@@ -81,7 +84,7 @@ export interface ChangeLog {
website_id: string | null; website_id: string | null;
user_id: string | null; user_id: string | null;
username: string; username: string;
tstamp: Date; tstamp: string;
table_name: string; table_name: string;
operation: string; operation: string;
old_value: any | null; old_value: any | null;
@@ -92,7 +95,7 @@ export interface ChangeLogInput {
website_id?: string | null; website_id?: string | null;
user_id?: string | null; user_id?: string | null;
username?: string; username?: string;
tstamp?: Date; tstamp?: string;
table_name: string; table_name: string;
operation: string; operation: string;
old_value?: any | null; old_value?: any | null;
@@ -126,16 +129,16 @@ export interface Collab {
website_id: string; website_id: string;
user_id: string; user_id: string;
permission_level: number; permission_level: number;
added_at: Date; added_at: string;
last_modified_at: Date; last_modified_at: string;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface CollabInput { export interface CollabInput {
website_id: string; website_id: string;
user_id: string; user_id: string;
permission_level?: number; permission_level?: number;
added_at?: Date; added_at?: string;
last_modified_at?: Date; last_modified_at?: string;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const collab = { const collab = {
@@ -166,8 +169,8 @@ export interface DocsCategory {
user_id: string | null; user_id: string | null;
category_name: string; category_name: string;
category_weight: number; category_weight: number;
created_at: Date; created_at: string;
last_modified_at: Date; last_modified_at: string;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface DocsCategoryInput { export interface DocsCategoryInput {
@@ -176,8 +179,8 @@ export interface DocsCategoryInput {
user_id?: string | null; user_id?: string | null;
category_name: string; category_name: string;
category_weight: number; category_weight: number;
created_at?: Date; created_at?: string;
last_modified_at?: Date; last_modified_at?: string;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const docs_category = { const docs_category = {
@@ -203,45 +206,17 @@ const docs_category = {
$input: null as unknown as DocsCategoryInput $input: null as unknown as DocsCategoryInput
} as const; } 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 // Table footer
export interface Footer { export interface Footer {
website_id: string; website_id: string;
additional_text: string; additional_text: string;
last_modified_at: Date; last_modified_at: string;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface FooterInput { export interface FooterInput {
website_id: string; website_id: string;
additional_text: string; additional_text: string;
last_modified_at?: Date; last_modified_at?: string;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const footer = { const footer = {
@@ -263,7 +238,7 @@ export interface Header {
logo_type: string; logo_type: string;
logo_text: string | null; logo_text: string | null;
logo_image: string | null; logo_image: string | null;
last_modified_at: Date; last_modified_at: string;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface HeaderInput { export interface HeaderInput {
@@ -271,7 +246,7 @@ export interface HeaderInput {
logo_type?: string; logo_type?: string;
logo_text?: string | null; logo_text?: string | null;
logo_image?: string | null; logo_image?: string | null;
last_modified_at?: Date; last_modified_at?: string;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const header = { const header = {
@@ -300,14 +275,14 @@ export interface Home {
website_id: string; website_id: string;
main_content: string; main_content: string;
meta_description: string | null; meta_description: string | null;
last_modified_at: Date; last_modified_at: string;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface HomeInput { export interface HomeInput {
website_id: string; website_id: string;
main_content: string; main_content: string;
meta_description?: string | null; meta_description?: string | null;
last_modified_at?: Date; last_modified_at?: string;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const home = { const home = {
@@ -329,34 +304,6 @@ const home = {
$input: null as unknown as HomeInput $input: null as unknown as HomeInput
} as const; } 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 // Table media
export interface Media { export interface Media {
id: string; id: string;
@@ -365,7 +312,7 @@ export interface Media {
blob: string; blob: string;
mimetype: string; mimetype: string;
original_name: string; original_name: string;
created_at: Date; created_at: string;
} }
export interface MediaInput { export interface MediaInput {
id?: string; id?: string;
@@ -374,7 +321,7 @@ export interface MediaInput {
blob: string; blob: string;
mimetype: string; mimetype: string;
original_name: string; original_name: string;
created_at?: Date; created_at?: string;
} }
const media = { const media = {
tableName: "media", tableName: "media",
@@ -397,7 +344,7 @@ export interface Settings {
background_color_dark_theme: string; background_color_dark_theme: string;
background_color_light_theme: string; background_color_light_theme: string;
favicon_image: string | null; favicon_image: string | null;
last_modified_at: Date; last_modified_at: string;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface SettingsInput { export interface SettingsInput {
@@ -407,7 +354,7 @@ export interface SettingsInput {
background_color_dark_theme?: string; background_color_dark_theme?: string;
background_color_light_theme?: string; background_color_light_theme?: string;
favicon_image?: string | null; favicon_image?: string | null;
last_modified_at?: Date; last_modified_at?: string;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const settings = { const settings = {
@@ -440,7 +387,7 @@ export interface User {
password_hash: string; password_hash: string;
user_role: string; user_role: string;
max_number_websites: number; max_number_websites: number;
created_at: Date; created_at: string;
} }
export interface UserInput { export interface UserInput {
id?: string; id?: string;
@@ -448,7 +395,7 @@ export interface UserInput {
password_hash: string; password_hash: string;
user_role?: string; user_role?: string;
max_number_websites?: number; max_number_websites?: number;
created_at?: Date; created_at?: string;
} }
const user = { const user = {
tableName: "user", tableName: "user",
@@ -466,10 +413,10 @@ export interface Website {
user_id: string; user_id: string;
content_type: string; content_type: string;
title: string; title: string;
slug: string | null;
max_storage_size: number; max_storage_size: number;
is_published: boolean; created_at: string;
created_at: Date; last_modified_at: string;
last_modified_at: Date;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface WebsiteInput { export interface WebsiteInput {
@@ -477,10 +424,10 @@ export interface WebsiteInput {
user_id?: string; user_id?: string;
content_type: string; content_type: string;
title: string; title: string;
slug?: string | null;
max_storage_size?: number; max_storage_size?: number;
is_published?: boolean; created_at?: string;
created_at?: Date; last_modified_at?: string;
last_modified_at?: Date;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const website = { const website = {
@@ -490,8 +437,8 @@ const website = {
"user_id", "user_id",
"content_type", "content_type",
"title", "title",
"slug",
"max_storage_size", "max_storage_size",
"is_published",
"created_at", "created_at",
"last_modified_at", "last_modified_at",
"last_modified_by" "last_modified_by"
@@ -523,10 +470,6 @@ export interface TableTypes {
select: DocsCategory; select: DocsCategory;
input: DocsCategoryInput; input: DocsCategoryInput;
}; };
domain_prefix: {
select: DomainPrefix;
input: DomainPrefixInput;
};
footer: { footer: {
select: Footer; select: Footer;
input: FooterInput; input: FooterInput;
@@ -539,10 +482,6 @@ export interface TableTypes {
select: Home; select: Home;
input: HomeInput; input: HomeInput;
}; };
legal_information: {
select: LegalInformation;
input: LegalInformationInput;
};
media: { media: {
select: Media; select: Media;
input: MediaInput; input: MediaInput;
@@ -566,11 +505,9 @@ export const tables = {
change_log, change_log,
collab, collab,
docs_category, docs_category,
domain_prefix,
footer, footer,
header, header,
home, home,
legal_information,
media, media,
settings, settings,
user, user,

View File

@@ -19,11 +19,13 @@ export const apiRequest = async (
body?: any; body?: any;
successMessage?: string; successMessage?: string;
returnData?: boolean; returnData?: boolean;
noJSONTransform?: boolean;
} = { } = {
headers: {}, headers: {},
body: undefined, body: undefined,
successMessage: "Operation was successful", successMessage: "Operation was successful",
returnData: false returnData: false,
noJSONTransform: false
} }
) => { ) => {
const headers = { const headers = {
@@ -48,7 +50,7 @@ export const apiRequest = async (
return { return {
success: true, success: true,
message: options.successMessage, 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"> <script lang="ts">
import { slugify, type WebsiteOverview } from "../../utils"; import { type WebsiteOverview } from "../utils";
const { const {
websiteOverview, websiteOverview,
nestingLevel, nestingLevel,
apiUrl, apiUrl,
title, title,
slug,
metaDescription, metaDescription,
websiteUrl websiteUrl
}: { }: {
@@ -13,6 +14,7 @@
nestingLevel: number; nestingLevel: number;
apiUrl: string; apiUrl: string;
title: string; title: string;
slug?: string;
metaDescription?: string | null; metaDescription?: string | null;
websiteUrl: string; websiteUrl: string;
} = $props(); } = $props();
@@ -20,7 +22,7 @@
const constructedTitle = const constructedTitle =
websiteOverview.title === title ? title : `${websiteOverview.title} | ${title}`; 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> </script>
<svelte:head> <svelte:head>

View File

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

View File

@@ -1,19 +1,17 @@
<script lang="ts"> <script lang="ts">
import { type WebsiteOverview, slugify } from "../../utils"; import { type WebsiteOverview } from "../utils";
import type { Article } from "../../db-schema"; import type { Article } from "../db-schema";
const { const {
websiteOverview, websiteOverview,
isDocsTemplate, isDocsTemplate,
isIndexPage, isIndexPage,
apiUrl, apiUrl
isLegalPage
}: { }: {
websiteOverview: WebsiteOverview; websiteOverview: WebsiteOverview;
isDocsTemplate: boolean; isDocsTemplate: boolean;
isIndexPage: boolean; isIndexPage: boolean;
apiUrl: string; apiUrl: string;
isLegalPage?: boolean;
} = $props(); } = $props();
const categorizedArticles = Object.fromEntries( const categorizedArticles = Object.fromEntries(
@@ -61,9 +59,9 @@
<li> <li>
<strong>{key}</strong> <strong>{key}</strong>
<ul> <ul>
{#each categorizedArticles[key] as { title }} {#each categorizedArticles[key] as { title, slug }}
<li> <li>
<a href="{isIndexPage ? './articles' : '.'}/{slugify(title)}">{title}</a> <a href="{isIndexPage ? './articles' : '.'}/{slug}">{title}</a>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -72,22 +70,19 @@
</ul> </ul>
</section> </section>
{/if} {/if}
<svelte:element <svelte:element this={isIndexPage ? "span" : "a"} href={`${isIndexPage ? "./" : "../"}`}>
this={isIndexPage && !isLegalPage ? "span" : "a"}
href={`${isLegalPage ? "./" : "../"}`}
>
{#if websiteOverview.header.logo_type === "text"} {#if websiteOverview.header.logo_type === "text"}
<strong>{websiteOverview.header.logo_text}</strong> <strong>{websiteOverview.header.logo_text}</strong>
{:else} {:else}
<img <img
src="{apiUrl}/rpc/retrieve_file?id={websiteOverview.header.logo_image}" src="{apiUrl}/rpc/retrieve_file?id={websiteOverview.header.logo_image}"
width="24" width="32"
height="24" height="32"
alt="" alt=""
/> />
{/if} {/if}
</svelte:element> </svelte:element>
<label style="margin-inline-start: auto;" for="toggle-theme"> <label for="toggle-theme">
<input type="checkbox" id="toggle-theme" hidden /> <input type="checkbox" id="toggle-theme" hidden />
<svg <svg
xmlns="http://www.w3.org/2000/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, Footer,
Article, Article,
DocsCategory, DocsCategory,
LegalInformation, User
DomainPrefix
} from "$lib/db-schema"; } from "$lib/db-schema";
import type { SubmitFunction } from "@sveltejs/kit"; import type { SubmitFunction } from "@sveltejs/kit";
import { sending } from "./runes.svelte"; import { sending } from "./runes.svelte";
@@ -26,7 +25,7 @@ export const ALLOWED_MIME_TYPES = [
"image/svg+xml" "image/svg+xml"
]; ];
export const slugify = (string: string) => { const slugify = (string: string) => {
return string return string
.toString() .toString()
.normalize("NFKD") // Normalize Unicode characters .normalize("NFKD") // Normalize Unicode characters
@@ -221,6 +220,5 @@ export interface WebsiteOverview extends Website {
home: Home; home: Home;
footer: Footer; footer: Footer;
article: (Article & { docs_category: DocsCategory | null })[]; article: (Article & { docs_category: DocsCategory | null })[];
legal_information?: LegalInformation; user: User;
domain_prefix?: DomainPrefix;
} }

View File

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

View File

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

View File

@@ -77,23 +77,18 @@
</details> </details>
<ul class="website-grid unpadded"> <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"> <li class="website-card">
<p> <p>
<span>({content_type})</span>
<strong> <strong>
<a href="/website/{id}">{title}</a> <a href="/website/{id}">{title}</a>
</strong> </strong>
</p> </p>
<ul> <p>
<li> <strong>Last modified:</strong>
<strong>Type:</strong> <DateTime date={last_modified_at} />
{content_type} </p>
</li>
<li>
<strong>Created at:</strong>
<DateTime date={created_at} />
</li>
</ul>
<div class="website-card__actions"> <div class="website-card__actions">
<Modal id="update-website-{id}" text="Update"> <Modal id="update-website-{id}" text="Update">
<h4>Update website</h4> <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 }) => { export const load: PageServerLoad = async ({ fetch, locals }) => {
const storageSizes = await apiRequest( const storageSizes = await apiRequest(
fetch, fetch,
`${API_BASE_PREFIX}/rpc/user_websites_storage_size`, `${API_BASE_PREFIX}/rpc/user_websites_storage_size?order=max_storage_bytes.desc`,
"GET", "GET",
{ {
returnData: true returnData: true

View File

@@ -10,7 +10,7 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
const usersWithWebsites: (User & { website: Website[] })[] = ( const usersWithWebsites: (User & { website: Website[] })[] = (
await apiRequest( await apiRequest(
fetch, 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", "GET",
{ {
returnData: true returnData: true
@@ -61,7 +61,7 @@ export const actions: Actions = {
body: { body: {
max_storage_size: data.get("storage-size") 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 type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX } from "$lib/server/utils";
import { apiRequest } from "$lib/server/utils"; import { apiRequest } from "$lib/server/utils";
import { parse } from "node:path";
import type { Article, DocsCategory } from "$lib/db-schema"; import type { Article, DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch, url, parent, locals }) => { 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, website,
home, home,
permissionLevel, permissionLevel,
API_BASE_PREFIX,
user: locals.user user: locals.user
}; };
}; };
@@ -74,6 +76,25 @@ export const actions: Actions = {
successMessage: "Successfully created article" 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 }) => { deleteArticle: async ({ request, fetch }) => {
const data = await request.formData(); const data = await request.formData();

View File

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

View File

@@ -36,7 +36,13 @@
<form method="POST" action="?/createCategory" use:enhance={enhanceForm({ closeModal: true })}> <form method="POST" action="?/createCategory" use:enhance={enhanceForm({ closeModal: true })}>
<label> <label>
Name: Name:
<input type="text" name="category-name" maxlength="50" required /> <input
type="text"
name="category-name"
maxlength="50"
pattern="^(?!Uncategorized$).+$"
required
/>
</label> </label>
<label> <label>
@@ -80,6 +86,7 @@
name="category-name" name="category-name"
value={category_name} value={category_name}
maxlength="50" maxlength="50"
pattern="^(?!Uncategorized$).+$"
required required
/> />
</label> </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 resourceFilter = url.searchParams.get("resource");
const operationFilter = url.searchParams.get("operation"); const operationFilter = url.searchParams.get("operation");
const currentPage = Number.parseInt(url.searchParams.get("page") ?? "1"); const currentPage = Number.parseInt(url.searchParams.get("page") ?? "1");
const sinceTime = url.searchParams.get("since");
const resultOffset = (currentPage - 1) * PAGINATION_MAX_ITEMS; const resultOffset = (currentPage - 1) * PAGINATION_MAX_ITEMS;
const searchParams = new URLSearchParams(); 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`; 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") { if (userFilter && userFilter !== "all") {
searchParams.append("username", `eq.${userFilter}`); searchParams.append("username", `eq.${userFilter}`);
} }

View File

@@ -96,6 +96,7 @@
</select> </select>
</label> </label>
<input type="hidden" name="page" value={1} /> <input type="hidden" name="page" value={1} />
<input type="hidden" name="since" value={$page.url.searchParams.get("since")} />
<button type="submit">Apply</button> <button type="submit">Apply</button>
</form> </form>
</details> </details>
@@ -106,7 +107,7 @@
<th>User</th> <th>User</th>
<th>Resource</th> <th>Resource</th>
<th>Operation</th> <th>Operation</th>
<th>Date & Time</th> <th>Time</th>
<th>Changes</th> <th>Changes</th>
</tr> </tr>
</thead> </thead>
@@ -140,21 +141,20 @@
<button type="submit">Compute diff</button> <button type="submit">Compute diff</button>
</form> </form>
{#if form?.logId === id && form?.currentDiff} {#if form?.logId === id && form?.currentDiff}
<pre style="white-space: pre-wrap">{@html DOMPurify.sanitize( <pre>{@html DOMPurify.sanitize(form.currentDiff, {
form.currentDiff, ALLOWED_TAGS: ["ins", "del"]
{ ALLOWED_TAGS: ["ins", "del"] } })}</pre>
)}</pre>
{/if} {/if}
{/if} {/if}
{#if new_value && !old_value} {#if new_value && !old_value}
<h4>New value</h4> <h4>New value</h4>
<pre style="white-space: pre-wrap">{DOMPurify.sanitize(newValue)}</pre> <pre>{DOMPurify.sanitize(newValue)}</pre>
{/if} {/if}
{#if old_value && !new_value} {#if old_value && !new_value}
<h4>Old value</h4> <h4>Old value</h4>
<pre style="white-space: pre-wrap">{DOMPurify.sanitize(oldValue)}</pre> <pre>{DOMPurify.sanitize(oldValue)}</pre>
{/if} {/if}
</Modal> </Modal>
</td> </td>
@@ -164,8 +164,14 @@
</table> </table>
</div> </div>
<Pagination <Pagination
commonFilters={["user", "resource", "operation"]} commonFilters={["user", "resource", "operation", "since"]}
resultCount={data.resultChangeLogCount} resultCount={data.resultChangeLogCount}
/> />
</section> </section>
</WebsiteEditor> </WebsiteEditor>
<style>
pre {
white-space: pre-wrap;
}
</style>

View File

@@ -1,48 +1,75 @@
import { dev } from "$app/environment"; import { dev } from "$app/environment";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import BlogArticle from "$lib/templates/blog/BlogArticle.svelte"; import Index from "$lib/templates/Index.svelte";
import BlogIndex from "$lib/templates/blog/BlogIndex.svelte"; import Article from "$lib/templates/Article.svelte";
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte"; import { type WebsiteOverview, hexToHSL } from "$lib/utils";
import DocsIndex from "$lib/templates/docs/DocsIndex.svelte"; import { mkdir, writeFile, chmod, readdir, rm, readFile } from "node:fs/promises";
import { type WebsiteOverview, hexToHSL, slugify } from "$lib/utils";
import { mkdir, readFile, rename, writeFile, chmod, readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { render } from "svelte/server"; import { render } from "svelte/server";
import type { Actions, PageServerLoad } from "./$types"; 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 }) => { export const load: PageServerLoad = async ({ params, fetch, parent }) => {
const websiteOverview: WebsiteOverview = ( const websiteOverview: WebsiteOverview = (
await apiRequest( await apiRequest(fetch, getOverviewFetchUrl(params.websiteId), "GET", {
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`,
"GET",
{
headers: { headers: {
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
}, },
returnData: true returnData: true
} })
)
).data; ).data;
const { websitePreviewUrl, websiteProdUrl } = await generateStaticFiles(websiteOverview); 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 { return {
websiteOverview, websiteOverview,
websitePreviewUrl, websitePreviewUrl,
websiteProdUrl, websiteProdUrl,
permissionLevel permissionLevel,
prodIsGenerated,
currentMeta,
website
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
publishWebsite: async ({ fetch, params }) => { publishWebsite: async ({ fetch, params, locals }) => {
const websiteOverview: WebsiteOverview = ( const websiteOverview: WebsiteOverview = (
await apiRequest(fetch, getOverviewFetchUrl(params.websiteId), "GET", {
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
})
).data;
let permissionLevel = 40;
if (websiteOverview.user_id !== locals.user.id) {
permissionLevel = (
await apiRequest( await apiRequest(
fetch, fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`, `${API_BASE_PREFIX}/collab?select=permission_level&website_id=eq.${params.websiteId}&user_id=eq.${locals.user.id}`,
"GET", "GET",
{ {
headers: { headers: {
@@ -51,97 +78,24 @@ export const actions: Actions = {
returnData: true returnData: true
} }
) )
).data; ).data.permission_level;
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( if (permissionLevel < 30) {
join( return { success: false, message: "Insufficient permissions" };
"/",
"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",
{
headers: {
Prefer: "return=representation",
Accept: "application/vnd.pgrst.object+json"
},
successMessage: "Successfully deleted domain prefix",
returnData: true
}
);
if (!customPrefix.success) {
return customPrefix;
} }
await rename( await generateStaticFiles(websiteOverview, false, fetch);
join("/", "var", "www", "archtika-websites", customPrefix.data.prefix),
join("/", "var", "www", "archtika-websites", params.websiteId)
);
return customPrefix; 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 = `${ const websitePreviewUrl = `${
dev dev
? "http://localhost:18000" ? "http://localhost:18000"
@@ -151,13 +105,10 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
}/previews/${websiteData.id}/`; }/previews/${websiteData.id}/`;
const websiteProdUrl = dev 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
? process.env.ORIGIN.replace( ? `${process.env.ORIGIN.replace("//", `//${websiteData.user.username}.`)}/${websiteData.slug}`
"//", : `http://localhost:18000/${websiteData.user.username}/${websiteData.slug}`;
`//${websiteData.domain_prefix?.prefix ?? websiteData.id}.`
)
: `http://localhost:18000/${websiteData.domain_prefix?.prefix ?? websiteData.id}/`;
const fileContents = (head: string, body: string) => { const fileContents = (head: string, body: string) => {
return ` return `
@@ -172,11 +123,10 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
</html>`; </html>`;
}; };
const { head, body } = render(websiteData.content_type === "Blog" ? BlogIndex : DocsIndex, { const { head, body } = render(Index, {
props: { props: {
websiteOverview: websiteData, websiteOverview: websiteData,
apiUrl: API_BASE_PREFIX, apiUrl: API_BASE_PREFIX,
isLegalPage: false,
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
} }
}); });
@@ -185,24 +135,60 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
if (isPreview) { if (isPreview) {
uploadDir = join("/", "var", "www", "archtika-websites", "previews", websiteData.id); uploadDir = join("/", "var", "www", "archtika-websites", "previews", websiteData.id);
await mkdir(uploadDir, { recursive: true });
} else { } else {
uploadDir = join( uploadDir = join(
"/", "/",
"var", "var",
"www", "www",
"archtika-websites", "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 mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, ".publication-meta.json"), JSON.stringify(meta, null, 2));
}
await writeFile(join(uploadDir, "index.html"), fileContents(head, body)); await writeFile(join(uploadDir, "index.html"), fileContents(head, body));
await mkdir(join(uploadDir, "articles"), { await mkdir(join(uploadDir, "articles"), {
recursive: true recursive: true
}); });
for (const article of websiteData.article ?? []) { for (const article of websiteData.article ?? []) {
const { head, body } = render(websiteData.content_type === "Blog" ? BlogArticle : DocsArticle, { const { head, body } = render(Article, {
props: { props: {
websiteOverview: websiteData, websiteOverview: websiteData,
article, article,
@@ -211,23 +197,7 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
} }
}); });
await writeFile( await writeFile(join(uploadDir, "articles", `${article.slug}.html`), fileContents(head, body));
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));
} }
const variableStyles = await readFile(`${process.cwd()}/template-styles/variables.css`, { 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, "common.css"), commonStyles);
await writeFile(join(uploadDir, "scoped.css"), specificStyles); await writeFile(join(uploadDir, "scoped.css"), specificStyles);
await setPermissions(isPreview ? join(uploadDir, "../") : uploadDir); await setPermissions(join(uploadDir, "../"));
return { websitePreviewUrl, websiteProdUrl }; return { websitePreviewUrl, websiteProdUrl };
}; };
const setPermissions = async (dir: string) => { 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 }); const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry.name); const fullPath = join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await setPermissions(fullPath); await setPermissions(fullPath);
} else { } else {
await chmod(fullPath, 0o777); await chmod(fullPath, mode);
} }
} }
}; };

View File

@@ -4,10 +4,9 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; 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 { sending } from "$lib/runes.svelte";
import { previewContent } from "$lib/runes.svelte"; import { previewContent } from "$lib/runes.svelte";
import { enhanceForm } from "$lib/utils";
const { data, form }: { data: PageServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
@@ -31,10 +30,16 @@
<a href="#publish-website">Publish website</a> <a href="#publish-website">Publish website</a>
</h2> </h2>
<p> <p>
The preview area on this page allows you to see exactly how your website will look when it is Whenever you make changes, you will need to click the button below to make them visible on the
is published. If you are happy with the results, click the button below and your website will published website.
be published on the Internet.
</p> </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()}> <form method="POST" action="?/publishWebsite" use:enhance={enhanceForm()}>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)} <button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Publish website</button >Publish website</button
@@ -42,7 +47,7 @@
</form> </form>
</section> </section>
{#if data.websiteOverview.is_published} {#if data.prodIsGenerated}
<section id="publication-status"> <section id="publication-status">
<h2> <h2>
<a href="#publication-status">Publication status</a> <a href="#publication-status">Publication status</a>
@@ -52,51 +57,11 @@
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a> <a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
</p> </p>
</section> </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} {/if}
</WebsiteEditor> </WebsiteEditor>
<style>
.latest-changes-anchor {
max-inline-size: fit-content;
}
</style>

View File

@@ -34,14 +34,21 @@ header img {
nav, nav,
header, header,
main, main {
footer {
padding-block: var(--space-s); padding-block: var(--space-s);
} }
main {
padding-block-end: var(--space-xl);
}
footer { footer {
margin-block-start: auto; 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 { .articles ul {

View File

@@ -88,11 +88,16 @@ summary {
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
} }
summary:has(svg),
label:has(svg) { label:has(svg) {
display: inline-grid; display: inline-grid;
place-content: center; place-content: center;
} }
label[for="toggle-theme"] {
margin-inline-start: auto;
}
label[for="toggle-theme"] svg:first-of-type { label[for="toggle-theme"] svg:first-of-type {
display: var(--display-light); display: var(--display-light);
} }
@@ -113,6 +118,7 @@ label[for="toggle-theme"] svg:last-of-type {
} }
button:disabled { button:disabled {
user-select: none;
pointer-events: none; pointer-events: none;
opacity: 0.5; opacity: 0.5;
z-index: -10; z-index: -10;
@@ -272,7 +278,6 @@ table {
th, th,
td { td {
text-align: start;
padding: var(--space-2xs); padding: var(--space-2xs);
border: var(--border-primary); border: var(--border-primary);
} }

View File

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

View File

@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { import {
userOwner, userOwner,
authenticate, authenticate,
@@ -7,6 +8,8 @@ import {
collabTestingWebsite collabTestingWebsite
} from "./shared"; } from "./shared";
const genArticleName = () => randomBytes(12).toString("hex");
test.describe("Website owner", () => { test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page); await authenticate(userOwner, page);
@@ -21,7 +24,7 @@ test.describe("Website owner", () => {
test(`Create article`, async ({ page }) => { test(`Create article`, async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click(); 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:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article"); await page.locator("#create-article-modal").getByLabel("Title:").fill(genArticleName());
await page await page
.locator("#create-article-modal") .locator("#create-article-modal")
.getByRole("button", { name: "Create article" }) .getByRole("button", { name: "Create article" })
@@ -34,7 +37,7 @@ test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click(); 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:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article"); await page.locator("#create-article-modal").getByLabel("Title:").fill(genArticleName());
await page await page
.locator("#create-article-modal") .locator("#create-article-modal")
.getByRole("button", { name: "Create article" }) .getByRole("button", { name: "Create article" })
@@ -81,7 +84,7 @@ for (const permissionLevel of permissionLevels) {
test(`Create article`, async ({ page }) => { test(`Create article`, async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click(); 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:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article"); await page.locator("#create-article-modal").getByLabel("Title:").fill(genArticleName());
await page await page
.locator("#create-article-modal") .locator("#create-article-modal")
.getByRole("button", { name: "Create article" }) .getByRole("button", { name: "Create article" })

View File

@@ -6,6 +6,9 @@ import {
collabUsers, collabUsers,
collabTestingWebsite collabTestingWebsite
} from "./shared"; } from "./shared";
import { randomBytes } from "node:crypto";
const genWebsiteName = () => randomBytes(12).toString("hex");
test.describe("Website owner", () => { test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -16,7 +19,7 @@ test.describe("Website owner", () => {
await page.getByRole("button", { name: "Create website" }).click(); await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type:").selectOption("Blog"); await page.getByLabel("Type:").selectOption("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").click(); 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 await page
.locator("#create-website-modal") .locator("#create-website-modal")
.getByRole("button", { name: "Create website" }) .getByRole("button", { name: "Create website" })
@@ -32,7 +35,7 @@ test.describe("Website owner", () => {
await page.getByRole("button", { name: "Create website" }).click(); await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type:").selectOption("Blog"); await page.getByLabel("Type:").selectOption("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").click(); 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 await page
.locator("#create-website-modal") .locator("#create-website-modal")
.getByRole("button", { name: "Create website" }) .getByRole("button", { name: "Create website" })
@@ -48,7 +51,7 @@ test.describe("Website owner", () => {
.click(); .click();
const modalName = page.url().split("#")[1]; const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).getByLabel("Title:").click(); 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 page.getByRole("button", { name: "Update website" }).click();
await expect(page.getByText("Successfully updated website")).toBeVisible(); 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") .locator("details")
.getByRole("button", { name: "Update storage limit" }) .getByRole("button", { name: "Update storage limit" })
.click(); .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 }) => { 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("Successfully published website")).toBeVisible();
await expect(page.getByText("Your website is published at")).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) { for (const permissionLevel of permissionLevels) {
@@ -67,69 +48,5 @@ for (const permissionLevel of permissionLevels) {
await expect(page.getByText("Your website is published at")).toBeVisible(); 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();
}
});
}); });
} }