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

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

View File

@@ -95,6 +95,10 @@
tryFiles = "$uri $uri/ $uri.html =404"; tryFiles = "$uri $uri/ $uri.html =404";
}; };
}; };
extraConfig = ''
port_in_redirect off;
absolute_redirect off;
'';
}; };
}; };
}; };

View File

@@ -194,6 +194,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 = {
@@ -209,6 +214,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

@@ -29,16 +29,16 @@ 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.immutable_unaccent (TEXT) CREATE FUNCTION internal.generate_slug (TEXT)
RETURNS TEXT RETURNS TEXT
AS $$ AS $$
SELECT SELECT
unaccent ($1); REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(LOWER(TRIM(REGEXP_REPLACE(unaccent ($1), '\s+', '-', 'g'))), '[^\w-]', '', 'g'), '-+', '-', 'g'), '^-+', '', 'g'), '-+$', '', 'g')
$$ $$
LANGUAGE sql LANGUAGE sql
IMMUTABLE; IMMUTABLE;
GRANT EXECUTE ON FUNCTION internal.immutable_unaccent TO authenticated_user; 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 (),
@@ -54,11 +54,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 (
@@ -118,7 +119,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 (REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(LOWER(TRIM(REGEXP_REPLACE(internal.immutable_unaccent (title), '\s+', '-', 'g'))), '[^\w-]', '', 'g'), '-+', '-', 'g'), '^-+', '', 'g'), '-+$', '', 'g')) STORED, 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,
@@ -140,14 +141,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,
@@ -161,8 +154,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;
@@ -183,7 +174,7 @@ DROP TABLE internal.user;
DROP SCHEMA api; DROP SCHEMA api;
DROP FUNCTION internal.immutable_unaccent; DROP FUNCTION internal.generate_slug;
DROP SCHEMA internal; DROP SCHEMA internal;

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

@@ -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,108 +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]+)*$' AND prefix != 'previews'),
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 ON internal.domain_prefix TO authenticated_user;
GRANT SELECT 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 FUNCTION api.set_domain_prefix (website_id UUID, prefix VARCHAR(16), OUT was_set BOOLEAN)
AS $$
DECLARE
_has_access BOOLEAN;
_old_domain_prefix VARCHAR(16);
_base_path CONSTANT TEXT := '/var/www/archtika-websites/';
_old_path TEXT;
_new_path TEXT;
BEGIN
_has_access = internal.user_has_website_access (set_domain_prefix.website_id, 30);
SELECT
d.prefix INTO _old_domain_prefix
FROM
internal.domain_prefix AS d
WHERE
d.website_id = set_domain_prefix.website_id;
INSERT INTO internal.domain_prefix (website_id, prefix)
VALUES (set_domain_prefix.website_id, set_domain_prefix.prefix)
ON CONFLICT ON CONSTRAINT domain_prefix_pkey
DO UPDATE SET
prefix = EXCLUDED.prefix;
_old_path = _base_path || COALESCE(_old_domain_prefix, set_domain_prefix.website_id::TEXT);
_new_path = _base_path || set_domain_prefix.prefix;
IF _old_path != _new_path THEN
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''mv -T %s %s''', _old_path, _new_path);
END IF;
was_set := TRUE;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.set_domain_prefix TO authenticated_user;
CREATE FUNCTION api.delete_domain_prefix (website_id UUID, OUT was_deleted BOOLEAN)
AS $$
DECLARE
_has_access BOOLEAN;
_old_domain_prefix VARCHAR(16);
_base_path CONSTANT TEXT := '/var/www/archtika-websites/';
_old_path TEXT;
_new_path TEXT;
BEGIN
_has_access = internal.user_has_website_access (delete_domain_prefix.website_id, 30);
DELETE FROM internal.domain_prefix AS d
WHERE d.website_id = delete_domain_prefix.website_id
RETURNING
prefix INTO _old_domain_prefix;
_old_path = _base_path || _old_domain_prefix;
_new_path = _base_path || delete_domain_prefix.website_id;
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''mv -T %s %s''', _old_path, _new_path);
was_deleted := TRUE;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.delete_domain_prefix TO authenticated_user;
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 FUNCTION api.set_domain_prefix;
DROP FUNCTION api.delete_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;
@@ -67,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;
@@ -109,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
@@ -129,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
@@ -159,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,28 +4,32 @@ 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; _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 AS 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);
ELSIF TG_TABLE_NAME = 'article' THEN ELSIF TG_TABLE_NAME = 'article' THEN
SELECT SELECT
a.slug INTO _article_slug a.slug INTO _article_slug
@@ -33,11 +37,7 @@ BEGIN
internal.article AS a internal.article AS a
WHERE WHERE
a.id = OLD.id; a.id = OLD.id;
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/articles/%s.html''', _preview_path, _article_slug); EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''rm -f %s/articles/%s.html''', _preview_path, _article_slug);
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/articles/%s.html''', _prod_path, _article_slug);
ELSIF TG_TABLE_NAME = 'legal_information' THEN
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _preview_path);
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _prod_path);
END IF; END IF;
RETURN COALESCE(NEW, OLD); RETURN COALESCE(NEW, OLD);
END; END;
@@ -46,7 +46,7 @@ LANGUAGE plpgsql
SECURITY DEFINER; SECURITY DEFINER;
CREATE TRIGGER _cleanup_filesystem_website CREATE TRIGGER _cleanup_filesystem_website
BEFORE DELETE ON internal.website BEFORE UPDATE OR DELETE ON internal.website
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem (); EXECUTE FUNCTION internal.cleanup_filesystem ();
@@ -55,17 +55,10 @@ CREATE TRIGGER _cleanup_filesystem_article
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem (); EXECUTE FUNCTION internal.cleanup_filesystem ();
CREATE TRIGGER _cleanup_filesystem_legal_information
BEFORE DELETE ON internal.legal_information
FOR EACH ROW
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_article ON internal.article; DROP TRIGGER _cleanup_filesystem_article ON internal.article;
DROP TRIGGER _cleanup_filesystem_legal_information ON internal.legal_information;
DROP FUNCTION internal.cleanup_filesystem; DROP FUNCTION internal.cleanup_filesystem;

View File

@@ -9,35 +9,28 @@ DECLARE
_markdown_dir TEXT := '/tmp/website-' || export_articles_zip.website_id; _markdown_dir TEXT := '/tmp/website-' || export_articles_zip.website_id;
BEGIN BEGIN
_has_access = internal.user_has_website_access (export_articles_zip.website_id, 20); _has_access = internal.user_has_website_access (export_articles_zip.website_id, 20);
SELECT SELECT
FORMAT('[{ "Content-Type": "application/gzip" },' FORMAT('[{ "Content-Type": "application/gzip" },'
'{ "Content-Disposition": "attachment; filename=\"%s\"" }]', '{ "Content-Disposition": "attachment; filename=\"%s\"" }]', 'archtika-export-articles-' || export_articles_zip.website_id || '.tar.gz') INTO _headers;
'archtika-export-articles-' || export_articles_zip.website_id || '.tar.gz') INTO _headers;
PERFORM PERFORM
SET_CONFIG('response.headers', _headers, TRUE); SET_CONFIG('response.headers', _headers, TRUE);
EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''mkdir -p %s''', _markdown_dir || '/articles');
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''mkdir -p %s''', _markdown_dir || '/articles');
FOR _article IN ( FOR _article IN (
SELECT a.id, a.website_id, a.slug, a.main_content SELECT
FROM internal.article AS a a.id,
WHERE a.website_id = export_articles_zip.website_id) a.website_id,
LOOP a.slug,
EXECUTE FORMAT( a.main_content
'COPY (SELECT %L) TO ''%s''', FROM
_article.main_content, internal.article AS a
_markdown_dir || '/articles/' || _article.slug || '.md' WHERE
); a.website_id = export_articles_zip.website_id)
END LOOP; 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 '''') TO PROGRAM ''tar -czf %s -C %s articles && rm %s''', EXECUTE FORMAT('COPY (SELECT 1) TO PROGRAM ''sed -i "s/^\"//;s/\"$//;s/\"\"/\"/g" %s''', _markdown_dir || '/articles/' || _article.slug || '.md');
_markdown_dir || '/export.tar.gz', END LOOP;
_markdown_dir, 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');
_markdown_dir || '/articles/*.md' RETURN PG_READ_BINARY_FILE(_markdown_dir || '/export.tar.gz');
);
RETURN pg_read_binary_file(_markdown_dir || '/export.tar.gz');
END; END;
$$ $$
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -47,3 +40,4 @@ GRANT EXECUTE ON FUNCTION api.export_articles_zip TO authenticated_user;
-- migrate:down -- migrate:down
DROP FUNCTION api.export_articles_zip; DROP FUNCTION api.export_articles_zip;

1060
web-app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
"@types/eslint__js": "8.42.3", "@types/eslint__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

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

View File

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

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
* *
*/ */
@@ -206,34 +206,6 @@ 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: string;
last_modified_at: string;
last_modified_by: string | null;
}
export interface DomainPrefixInput {
website_id: string;
prefix: string;
created_at?: string;
last_modified_at?: string;
last_modified_by?: string | null;
}
const domain_prefix = {
tableName: "domain_prefix",
columns: ["website_id", "prefix", "created_at", "last_modified_at", "last_modified_by"],
requiredForInsert: ["website_id", "prefix"],
primaryKey: "website_id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as DomainPrefix,
$input: null as unknown as DomainPrefixInput
} as const;
// Table footer // Table footer
export interface Footer { export interface Footer {
website_id: string; website_id: string;
@@ -332,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: string;
last_modified_at: string;
last_modified_by: string | null;
}
export interface LegalInformationInput {
website_id: string;
main_content: string;
created_at?: string;
last_modified_at?: string;
last_modified_by?: string | null;
}
const legal_information = {
tableName: "legal_information",
columns: ["website_id", "main_content", "created_at", "last_modified_at", "last_modified_by"],
requiredForInsert: ["website_id", "main_content"],
primaryKey: "website_id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as LegalInformation,
$input: null as unknown as LegalInformationInput
} as const;
// Table media // Table media
export interface Media { export interface Media {
id: string; id: string;
@@ -469,8 +413,8 @@ 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: string;
last_modified_at: string; last_modified_at: string;
last_modified_by: string | null; last_modified_by: string | null;
@@ -480,8 +424,8 @@ 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?: string;
last_modified_at?: string; last_modified_at?: string;
last_modified_by?: string | null; last_modified_by?: string | null;
@@ -493,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"
@@ -526,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;
@@ -542,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;
@@ -569,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,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { type WebsiteOverview } from "../../utils"; import { type WebsiteOverview } from "../utils";
const { const {
websiteOverview, websiteOverview,
@@ -22,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 ? "" : `/${slug}`) : `/articles/${slug}`}`; 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 Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
import { md, type WebsiteOverview } from "$lib/utils"; import { md, type WebsiteOverview } from "$lib/utils";
import Head from "$lib/templates/Head.svelte";
import Nav from "$lib/templates/Nav.svelte";
import Footer from "$lib/templates/Footer.svelte";
const { 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>
@@ -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 } 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(
@@ -72,17 +70,14 @@
</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}

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,7 @@ import type {
Footer, 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";
@@ -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

@@ -77,27 +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, last_modified_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:</strong>
<DateTime date={created_at} />
</li>
<li>
<strong>Last modified:</strong>
<DateTime date={last_modified_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

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

View File

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

View File

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

View File

@@ -9,12 +9,17 @@ export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
const resourceFilter = url.searchParams.get("resource"); const 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>
@@ -163,7 +164,7 @@
</table> </table>
</div> </div>
<Pagination <Pagination
commonFilters={["user", "resource", "operation"]} commonFilters={["user", "resource", "operation", "since"]}
resultCount={data.resultChangeLogCount} resultCount={data.resultChangeLogCount}
/> />
</section> </section>

View File

@@ -1,17 +1,15 @@
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 DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
import { type WebsiteOverview, hexToHSL } from "$lib/utils"; import { type WebsiteOverview, hexToHSL } from "$lib/utils";
import { mkdir, readFile, writeFile, chmod, readdir } from "node:fs/promises"; import { mkdir, writeFile, chmod, readdir, rm, readFile } from "node:fs/promises";
import { join } from "node:path"; import { 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) => { const getOverviewFetchUrl = (websiteId: string) => {
return `${API_BASE_PREFIX}/website?id=eq.${websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`; return `${API_BASE_PREFIX}/website?id=eq.${websiteId}&select=*,user!user_id(*),settings(*),header(*),home(*),footer(*),article(*,docs_category(*))`;
}; };
export const load: PageServerLoad = async ({ params, fetch, parent }) => { export const load: PageServerLoad = async ({ params, fetch, parent }) => {
@@ -25,19 +23,37 @@ export const load: PageServerLoad = async ({ params, fetch, parent }) => {
).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", { await apiRequest(fetch, getOverviewFetchUrl(params.websiteId), "GET", {
headers: { headers: {
@@ -47,48 +63,39 @@ export const actions: Actions = {
}) })
).data; ).data;
const publish = await apiRequest( let permissionLevel = 40;
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`,
"PATCH",
{
body: {
is_published: true
},
successMessage: "Successfully published website"
}
);
if (!publish.success) { if (websiteOverview.user_id !== locals.user.id) {
return publish; permissionLevel = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/collab?select=permission_level&website_id=eq.${params.websiteId}&user_id=eq.${locals.user.id}`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
)
).data.permission_level;
} }
await generateStaticFiles(websiteOverview, false); if (permissionLevel < 30) {
return { success: false, message: "Insufficient permissions" };
}
return publish; await generateStaticFiles(websiteOverview, false, fetch);
},
createUpdateCustomDomainPrefix: async ({ request, fetch, params }) => {
const data = await request.formData();
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/set_domain_prefix`, "POST", { return { success: true, message: "Successfully published website" };
body: {
website_id: params.websiteId,
prefix: data.get("domain-prefix")
},
successMessage: "Successfully created/updated domain prefix"
});
},
deleteCustomDomainPrefix: async ({ fetch, params }) => {
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/delete_domain_prefix`, "POST", {
body: {
website_id: params.websiteId
},
successMessage: "Successfully deleted domain prefix"
});
} }
}; };
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"
@@ -98,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 `
@@ -119,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
} }
}); });
@@ -132,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 writeFile(join(uploadDir, ".publication-meta.json"), JSON.stringify(meta, null, 2));
} }
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, "index.html"), fileContents(head, body)); await 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,
@@ -161,19 +200,6 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
await writeFile(join(uploadDir, "articles", `${article.slug}.html`), fileContents(head, body)); await writeFile(join(uploadDir, "articles", `${article.slug}.html`), fileContents(head, body));
} }
if (websiteData.legal_information) {
const { head, body } = render(websiteData.content_type === "Blog" ? BlogIndex : DocsIndex, {
props: {
websiteOverview: websiteData,
apiUrl: API_BASE_PREFIX,
isLegalPage: true,
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
}
});
await writeFile(join(uploadDir, "legal-information.html"), fileContents(head, body));
}
const variableStyles = await readFile(`${process.cwd()}/template-styles/variables.css`, { const variableStyles = await readFile(`${process.cwd()}/template-styles/variables.css`, {
encoding: "utf-8" encoding: "utf-8"
}); });
@@ -237,7 +263,7 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
await writeFile(join(uploadDir, "common.css"), commonStyles); await writeFile(join(uploadDir, "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 };
}; };

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="^(?!previews$)[a-z]+(-[a-z]+)*$"
required
/>
</label>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Update domain prefix</button
>
</form>
{#if data.websiteOverview.domain_prefix?.prefix}
<Modal id="delete-domain-prefix" text="Delete">
<form
action="?/deleteCustomDomainPrefix"
method="post"
use:enhance={enhanceForm({ closeModal: true })}
>
<h3>Delete domain prefix</h3>
<p>
<strong>Caution!</strong>
This action will remove the domain prefix and reset it to its initial value.
</p>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Delete domain prefix</button
>
</form>
</Modal>
{/if}
</section>
{/if} {/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

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

View File

@@ -23,25 +23,6 @@ test.describe("Website owner", () => {
await expect(page.getByText("Successfully published website")).toBeVisible(); await expect(page.getByText("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();
}
});
}); });
} }