Add TypeScript definitions via pg-to-ts and refactor migrations

This commit is contained in:
thiloho
2024-09-10 17:29:57 +02:00
parent 8121be1d96
commit c5fbcdc8bd
50 changed files with 1525 additions and 1632 deletions

View File

@@ -1,21 +1,25 @@
-- migrate:up
CREATE SCHEMA internal;
CREATE SCHEMA api;
CREATE ROLE authenticator LOGIN NOINHERIT NOCREATEDB NOCREATEROLE NOSUPERUSER;
CREATE ROLE anon NOLOGIN NOINHERIT;
GRANT USAGE ON SCHEMA api TO anon;
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
GRANT USAGE ON SCHEMA api TO authenticated_user;
CREATE ROLE authenticator LOGIN NOINHERIT NOCREATEDB NOCREATEROLE NOSUPERUSER;
GRANT anon TO authenticator;
GRANT authenticated_user TO authenticator;
CREATE SCHEMA internal;
GRANT USAGE ON SCHEMA api TO anon;
GRANT USAGE ON SCHEMA api TO authenticated_user;
GRANT USAGE ON SCHEMA internal TO authenticated_user;
ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
CREATE TABLE internal.user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
@@ -30,8 +34,10 @@ CREATE TABLE internal.website (
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
is_published BOOLEAN NOT NULL DEFAULT FALSE,
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,
title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED
);
CREATE TABLE internal.media (
@@ -70,19 +76,32 @@ CREATE TABLE internal.home (
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE TABLE internal.docs_category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
category_weight INTEGER CHECK (category_weight >= 0) NOT NULL,
UNIQUE (website_id, category_name),
UNIQUE (website_id, category_weight)
);
CREATE TABLE internal.article (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL,
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) != ''),
meta_description VARCHAR(250) CHECK (TRIM(meta_description) != ''),
meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''),
cover_image UUID REFERENCES internal.media (id) ON DELETE SET NULL,
publication_date DATE NOT NULL DEFAULT CURRENT_DATE,
publication_date DATE DEFAULT CURRENT_DATE,
main_content TEXT CHECK (TRIM(main_content) != ''),
category UUID REFERENCES internal.docs_category (id) ON DELETE SET NULL,
article_weight INTEGER CHECK (article_weight IS NULL OR article_weight >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
title_description_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(title, '') || ' ' || COALESCE(meta_description, ''))) STORED
);
CREATE TABLE internal.footer (
@@ -92,6 +111,13 @@ CREATE TABLE internal.footer (
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE TABLE internal.legal_information (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE TABLE internal.collab (
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
@@ -117,10 +143,14 @@ DROP TABLE internal.change_log;
DROP TABLE internal.collab;
DROP TABLE internal.legal_information;
DROP TABLE internal.footer;
DROP TABLE internal.article;
DROP TABLE internal.docs_category;
DROP TABLE internal.home;
DROP TABLE internal.header;
@@ -131,15 +161,17 @@ DROP TABLE internal.media;
DROP TABLE internal.website;
DROP SCHEMA api;
DROP TABLE internal.user;
DROP SCHEMA internal;
DROP SCHEMA api;
DROP ROLE authenticator;
DROP SCHEMA internal;
DROP ROLE anon;
DROP ROLE authenticated_user;
DROP ROLE authenticator;
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;

View File

@@ -1,6 +1,6 @@
-- migrate:up
CREATE FUNCTION pgrst_watch ()
RETURNS event_trigger
RETURNS EVENT_TRIGGER
AS $$
BEGIN
NOTIFY pgrst,

View File

@@ -45,23 +45,21 @@ CREATE TRIGGER encrypt_pass
FOR EACH ROW
EXECUTE FUNCTION internal.encrypt_pass ();
CREATE FUNCTION internal.user_role (username TEXT, PASSWORD TEXT)
RETURNS NAME
AS $$
CREATE FUNCTION internal.user_role (username TEXT, pass TEXT, OUT role_name NAME)
AS $$
BEGIN
RETURN (
SELECT
ROLE
FROM
internal.user AS u
WHERE
u.username = user_role.username
AND u.password_hash = CRYPT(user_role.password, u.password_hash));
SELECT
ROLE INTO role_name
FROM
internal.user AS u
WHERE
u.username = user_role.username
AND u.password_hash = CRYPT(user_role.pass, u.password_hash);
END;
$$
LANGUAGE plpgsql;
CREATE FUNCTION api.register (username TEXT, PASSWORD TEXT, OUT user_id UUID)
CREATE FUNCTION api.register (username TEXT, pass TEXT, OUT user_id UUID)
AS $$
DECLARE
_username_length_min CONSTANT INT := 3;
@@ -69,52 +67,48 @@ DECLARE
_password_length_min CONSTANT INT := 12;
_password_length_max CONSTANT INT := 128;
BEGIN
IF LENGTH(register.username)
NOT BETWEEN _username_length_min AND _username_length_max THEN
RAISE string_data_length_mismatch
USING message = FORMAT('Username must be between %s and %s characters long', _username_length_min, _username_length_max);
END IF;
IF EXISTS (
SELECT
1
FROM
internal.user AS u
WHERE
u.username = register.username) THEN
CASE WHEN LENGTH(register.username)
NOT BETWEEN _username_length_min AND _username_length_max THEN
RAISE string_data_length_mismatch
USING message = FORMAT('Username must be between %s and %s characters long', _username_length_min, _username_length_max);
WHEN EXISTS (
SELECT
1
FROM
internal.user AS u
WHERE
u.username = register.username) THEN
RAISE unique_violation
USING message = 'Username is already taken';
END IF;
IF LENGTH(register.password)
NOT BETWEEN _password_length_min AND _password_length_max THEN
RAISE string_data_length_mismatch
USING message = FORMAT('Password must be between %s and %s characters long', _password_length_min, _password_length_max);
END IF;
IF register.password !~ '[a-z]' THEN
USING message = 'Username is already taken';
WHEN LENGTH(register.pass) NOT BETWEEN _password_length_min AND _password_length_max THEN
RAISE string_data_length_mismatch
USING message = FORMAT('Password must be between %s and %s characters long', _password_length_min, _password_length_max);
WHEN register.pass !~ '[a-z]' THEN
RAISE invalid_parameter_value
USING message = 'Password must contain at least one lowercase letter';
END IF;
IF register.password !~ '[A-Z]' THEN
RAISE invalid_parameter_value
USING message = 'Password must contain at least one uppercase letter';
END IF;
IF register.password !~ '[0-9]' THEN
RAISE invalid_parameter_value
USING message = 'Password must contain at least one number';
END IF;
IF register.password !~ '[!@#$%^&*(),.?":{}|<>]' THEN
RAISE invalid_parameter_value
USING message = 'Password must contain at least one special character';
END IF;
INSERT INTO internal.user (username, password_hash)
VALUES (register.username, register.password)
RETURNING
id INTO user_id;
END;
WHEN register.pass !~ '[A-Z]' THEN
RAISE invalid_parameter_value
USING message = 'Password must contain at least one uppercase letter';
WHEN register.pass !~ '[0-9]' THEN
RAISE invalid_parameter_value
USING message = 'Password must contain at least one number';
WHEN register.pass !~ '[!@#$%^&*(),.?":{}|<>]' THEN
RAISE invalid_parameter_value
USING message = 'Password must contain at least one special character';
ELSE
INSERT
INTO internal.user (username, password_hash)
VALUES (register.username, register.pass)
RETURNING
id INTO user_id;
END
CASE;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
CREATE FUNCTION api.login (username TEXT, PASSWORD TEXT, OUT token TEXT)
CREATE FUNCTION api.login (username TEXT, pass TEXT, OUT token TEXT)
AS $$
DECLARE
_role NAME;
@@ -122,7 +116,7 @@ DECLARE
_exp INTEGER;
BEGIN
SELECT
internal.user_role (login.username, login.password) INTO _role;
internal.user_role (login.username, login.pass) INTO _role;
IF _role IS NULL THEN
RAISE invalid_password
USING message = 'Invalid username or password';
@@ -141,14 +135,14 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
CREATE FUNCTION api.delete_account (PASSWORD TEXT, OUT was_deleted BOOLEAN)
CREATE FUNCTION api.delete_account (pass TEXT, OUT was_deleted BOOLEAN)
AS $$
DECLARE
_username TEXT := CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'username';
_role NAME;
BEGIN
SELECT
internal.user_role (_username, delete_account.password) INTO _role;
internal.user_role (_username, delete_account.pass) INTO _role;
IF _role IS NULL THEN
RAISE invalid_password
USING message = 'Invalid password';
@@ -165,7 +159,13 @@ GRANT EXECUTE ON FUNCTION api.register (TEXT, TEXT) TO anon;
GRANT EXECUTE ON FUNCTION api.login (TEXT, TEXT) TO anon;
GRANT EXECUTE ON FUNCTION api.delete_account (TEXT) TO authenticated_user;
-- migrate:down
DROP TRIGGER encrypt_pass ON internal.user;
DROP TRIGGER ensure_user_role_exists ON internal.user;
DROP FUNCTION api.register (TEXT, TEXT);
DROP FUNCTION api.login (TEXT, TEXT);
@@ -174,12 +174,8 @@ DROP FUNCTION api.delete_account (TEXT);
DROP FUNCTION internal.user_role (TEXT, TEXT);
DROP TRIGGER encrypt_pass ON internal.user;
DROP FUNCTION internal.encrypt_pass ();
DROP TRIGGER ensure_user_role_exists ON internal.user;
DROP FUNCTION internal.check_role_exists ();
DROP EXTENSION pgjwt;

View File

@@ -2,8 +2,7 @@
CREATE VIEW api.account WITH ( security_invoker = ON
) AS
SELECT
id,
username
*
FROM
internal.user
WHERE
@@ -23,99 +22,70 @@ FROM
CREATE VIEW api.website WITH ( security_invoker = ON
) AS
SELECT
id,
user_id,
content_type,
title,
created_at,
last_modified_at,
last_modified_by
*
FROM
internal.website;
CREATE VIEW api.settings WITH ( security_invoker = ON
) AS
SELECT
website_id,
accent_color_light_theme,
accent_color_dark_theme,
favicon_image,
last_modified_at,
last_modified_by
*
FROM
internal.settings;
CREATE VIEW api.header WITH ( security_invoker = ON
) AS
SELECT
website_id,
logo_type,
logo_text,
logo_image,
last_modified_at,
last_modified_by
*
FROM
internal.header;
CREATE VIEW api.home WITH ( security_invoker = ON
) AS
SELECT
website_id,
main_content,
last_modified_at,
last_modified_by
*
FROM
internal.home;
CREATE VIEW api.article WITH ( security_invoker = ON
) AS
SELECT
id,
website_id,
user_id,
title,
meta_description,
meta_author,
cover_image,
publication_date,
main_content,
created_at,
last_modified_at,
last_modified_by
*
FROM
internal.article;
CREATE VIEW api.docs_category WITH ( security_invoker = ON
) AS
SELECT
*
FROM
internal.docs_category;
CREATE VIEW api.footer WITH ( security_invoker = ON
) AS
SELECT
website_id,
additional_text,
last_modified_at,
last_modified_by
*
FROM
internal.footer;
CREATE VIEW api.legal_information WITH ( security_invoker = ON
) AS
SELECT
*
FROM
internal.legal_information;
CREATE VIEW api.collab WITH ( security_invoker = ON
) AS
SELECT
website_id,
user_id,
permission_level,
added_at,
last_modified_at,
last_modified_by
*
FROM
internal.collab;
CREATE VIEW api.change_log WITH ( security_invoker = ON
) AS
SELECT
website_id,
user_id,
change_summary,
previous_value,
new_value,
timestamp
*
FROM
internal.change_log;
@@ -123,9 +93,8 @@ CREATE FUNCTION api.create_website (content_type VARCHAR(10), title VARCHAR(50),
AS $$
DECLARE
_website_id UUID;
_user_id UUID;
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
BEGIN
_user_id := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
INSERT INTO internal.website (content_type, title)
VALUES (create_website.content_type, create_website.title)
RETURNING
@@ -135,8 +104,7 @@ BEGIN
INSERT INTO internal.header (website_id, logo_text)
VALUES (_website_id, 'archtika ' || create_website.content_type);
INSERT INTO internal.home (website_id, main_content)
VALUES (_website_id, '
## About
VALUES (_website_id, '## About
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates.
@@ -148,8 +116,7 @@ For the backend, PostgreSQL is used in combination with PostgREST to create a RE
The web application uses SvelteKit with SSR (Server Side Rendering) and Svelte version 5, currently in beta.
NGINX is used to deploy the websites, serving the static site files from the `/var/www/archtika-websites` directory. The static files can be found in this directory via the path `<user_id>/<website_id>`, which is dynamically created by the web application.
');
NGINX is used to deploy the websites, serving the static site files from the `/var/www/archtika-websites` directory. The static files can be found in this directory via the path `<user_id>/<website_id>`, which is dynamically created by the web application.');
INSERT INTO internal.footer (website_id, additional_text)
VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS');
website_id := _website_id;
@@ -187,10 +154,18 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON internal.article TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.docs_category TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.docs_category TO authenticated_user;
GRANT SELECT, UPDATE ON internal.footer TO authenticated_user;
GRANT SELECT, UPDATE ON api.footer TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.legal_information TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.legal_information TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.collab TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
@@ -206,10 +181,14 @@ DROP VIEW api.change_log;
DROP VIEW api.collab;
DROP VIEW api.legal_information;
DROP VIEW api.footer;
DROP VIEW api.home;
DROP VIEW api.docs_category;
DROP VIEW api.article;
DROP VIEW api.header;

View File

@@ -13,29 +13,30 @@ ALTER TABLE internal.home ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.article ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.docs_category ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.footer ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INTEGER, collaborator_permission_level INTEGER DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE)
RETURNS BOOLEAN
AS $$
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INTEGER, collaborator_permission_level INTEGER DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE, OUT has_access BOOLEAN)
AS $$
DECLARE
_user_id UUID;
_has_access BOOLEAN;
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
BEGIN
_user_id := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
SELECT
EXISTS (
SELECT
1
FROM
internal.website
internal.website AS w
WHERE
id = website_id
AND user_id = _user_id) INTO _has_access;
IF _has_access THEN
RETURN _has_access;
w.id = user_has_website_access.website_id
AND w.user_id = _user_id) INTO has_access;
IF has_access THEN
RETURN;
END IF;
SELECT
EXISTS (
@@ -45,24 +46,25 @@ BEGIN
internal.collab c
WHERE
c.website_id = user_has_website_access.website_id
AND c.user_id = (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID
AND c.user_id = _user_id
AND c.permission_level >= user_has_website_access.required_permission
AND (user_has_website_access.article_user_id IS NULL
OR (c.permission_level = 30
OR user_has_website_access.article_user_id = _user_id))
AND (user_has_website_access.collaborator_permission_level IS NULL
OR (user_has_website_access.collaborator_user_id != _user_id
AND user_has_website_access.collaborator_permission_level < 30))) INTO _has_access;
IF NOT _has_access AND user_has_website_access.raise_error THEN
AND user_has_website_access.collaborator_permission_level < 30))) INTO has_access;
IF NOT has_access AND user_has_website_access.raise_error THEN
RAISE insufficient_privilege
USING message = 'You do not have the required permissions for this action.';
END IF;
RETURN _has_access;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION internal.user_has_website_access (UUID, INTEGER, INTEGER, UUID, UUID, BOOLEAN) TO authenticated_user;
CREATE POLICY view_user ON internal.user
FOR SELECT
USING (TRUE);
@@ -127,6 +129,22 @@ CREATE POLICY insert_article ON internal.article
FOR INSERT
WITH CHECK (internal.user_has_website_access (website_id, 20));
CREATE POLICY view_categories ON internal.docs_category
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
CREATE POLICY update_category ON internal.docs_category
FOR UPDATE
USING (internal.user_has_website_access (website_id, 20));
CREATE POLICY delete_category ON internal.docs_category
FOR DELETE
USING (internal.user_has_website_access (website_id, 20, article_user_id => user_id));
CREATE POLICY insert_category ON internal.docs_category
FOR INSERT
WITH CHECK (internal.user_has_website_access (website_id, 20));
CREATE POLICY view_footer ON internal.footer
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
@@ -135,6 +153,22 @@ CREATE POLICY update_footer ON internal.footer
FOR UPDATE
USING (internal.user_has_website_access (website_id, 20));
CREATE POLICY view_legal_information ON internal.legal_information
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
CREATE POLICY update_legal_information ON internal.legal_information
FOR UPDATE
USING (internal.user_has_website_access (website_id, 30));
CREATE POLICY delete_legal_information ON internal.legal_information
FOR DELETE
USING (internal.user_has_website_access (website_id, 30));
CREATE POLICY insert_legal_information ON internal.legal_information
FOR INSERT
WITH CHECK (internal.user_has_website_access (website_id, 30));
CREATE POLICY view_collaborations ON internal.collab
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
@@ -184,10 +218,26 @@ DROP POLICY delete_article ON internal.article;
DROP POLICY insert_article ON internal.article;
DROP POLICY view_categories ON internal.docs_category;
DROP POLICY update_category ON internal.docs_category;
DROP POLICY delete_category ON internal.docs_category;
DROP POLICY insert_category ON internal.docs_category;
DROP POLICY view_footer ON internal.footer;
DROP POLICY update_footer ON internal.footer;
DROP POLICY insert_legal_information ON internal.legal_information;
DROP POLICY delete_legal_information ON internal.legal_information;
DROP POLICY update_legal_information ON internal.legal_information;
DROP POLICY view_legal_information ON internal.legal_information;
DROP POLICY view_collaborations ON internal.collab;
DROP POLICY insert_collaborations ON internal.collab;
@@ -212,7 +262,11 @@ ALTER TABLE internal.home DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.article DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.docs_category DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.footer DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.legal_information DISABLE ROW LEVEL SECURITY;
ALTER TABLE internal.collab DISABLE ROW LEVEL SECURITY;

View File

@@ -1,40 +0,0 @@
-- migrate:up
CREATE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;
-- migrate:down
DROP VIEW api.website_overview;

View File

@@ -12,25 +12,12 @@ BEGIN
last_modified_at = NEW.last_modified_at,
last_modified_by = NEW.last_modified_by
WHERE
id = CASE WHEN TG_TABLE_NAME = 'settings' THEN
NEW.website_id
WHEN TG_TABLE_NAME = 'header' THEN
NEW.website_id
WHEN TG_TABLE_NAME = 'home' THEN
NEW.website_id
WHEN TG_TABLE_NAME = 'article' THEN
NEW.website_id
WHEN TG_TABLE_NAME = 'footer' THEN
NEW.website_id
WHEN TG_TABLE_NAME = 'collab' THEN
NEW.website_id
END;
id = NEW.website_id;
END IF;
RETURN NEW;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
LANGUAGE plpgsql;
CREATE TRIGGER update_website_last_modified
BEFORE UPDATE ON internal.website
@@ -62,6 +49,11 @@ CREATE TRIGGER update_footer_last_modified
FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified ();
CREATE TRIGGER update_legal_information_last_modified
BEFORE INSERT OR UPDATE OR DELETE ON internal.legal_information
FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified ();
CREATE TRIGGER update_collab_last_modified
BEFORE UPDATE ON internal.collab
FOR EACH ROW
@@ -80,6 +72,8 @@ DROP TRIGGER update_article_last_modified ON internal.article;
DROP TRIGGER update_footer_last_modified ON internal.footer;
DROP TRIGGER update_legal_information_last_modified ON internal.legal_information;
DROP TRIGGER update_collab_last_modified ON internal.collab;
DROP FUNCTION internal.update_last_modified ();

View File

@@ -7,10 +7,10 @@ BEGIN
SELECT
1
FROM
internal.website
internal.website AS w
WHERE
id = NEW.website_id
AND user_id = NEW.user_id) THEN
w.id = NEW.website_id
AND w.user_id = NEW.user_id) THEN
RAISE foreign_key_violation
USING message = 'User cannot be added as a collaborator to their own website';
END IF;

View File

@@ -10,7 +10,9 @@ DECLARE
_original_filename TEXT := _headers ->> 'x-original-filename';
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
_max_file_size INT := 5 * 1024 * 1024;
_has_access BOOLEAN;
BEGIN
_has_access = internal.user_has_website_access (_website_id, 20);
IF OCTET_LENGTH($1) = 0 THEN
RAISE invalid_parameter_value
USING message = 'No file data was provided';
@@ -19,7 +21,7 @@ BEGIN
SELECT
UNNEST(_allowed_mimetypes)) THEN
RAISE invalid_parameter_value
USING message = 'Invalid MIME type. Allowed types are: png, svg, jpg, webp';
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
END IF;
IF OCTET_LENGTH($1) > _max_file_size THEN
RAISE program_limit_exceeded
@@ -46,7 +48,7 @@ BEGIN
'{ "Content-Disposition": "inline; filename=\"%s\"" },'
'{ "Cache-Control": "max-age=259200" }]', m.mimetype, m.original_name)
FROM
internal.media m
internal.media AS m
WHERE
m.id = retrieve_file.id INTO _headers;
PERFORM

View File

@@ -1,90 +0,0 @@
-- migrate:up
ALTER TABLE internal.website
ADD COLUMN title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED;
CREATE OR REPLACE VIEW api.website WITH ( security_invoker = ON
) AS
SELECT
id,
user_id,
content_type,
title,
created_at,
last_modified_at,
last_modified_by,
title_search -- New column
FROM
internal.website;
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
ALTER TABLE internal.article
ADD COLUMN title_description_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(title, '') || ' ' || COALESCE(meta_description, ''))) STORED;
CREATE OR REPLACE VIEW api.article WITH ( security_invoker = ON
) AS
SELECT
id,
website_id,
user_id,
title,
meta_description,
meta_author,
cover_image,
publication_date,
main_content,
created_at,
last_modified_at,
last_modified_by,
title_description_search -- New column
FROM
internal.article;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
-- migrate:down
DROP VIEW api.article;
CREATE VIEW api.article WITH ( security_invoker = ON
) AS
SELECT
id,
website_id,
user_id,
title,
meta_description,
meta_author,
cover_image,
publication_date,
main_content,
created_at,
last_modified_at,
last_modified_by
FROM
internal.article;
ALTER TABLE internal.article
DROP COLUMN title_description_search;
DROP VIEW api.website;
CREATE VIEW api.website WITH ( security_invoker = ON
) AS
SELECT
id,
user_id,
content_type,
title,
created_at,
last_modified_at,
last_modified_by
FROM
internal.website;
ALTER TABLE internal.website
DROP COLUMN title_search;
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;

View File

@@ -1,74 +0,0 @@
-- migrate:up
CREATE OR REPLACE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
AS $$
DECLARE
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
_mimetype TEXT := _headers ->> 'x-mimetype';
_original_filename TEXT := _headers ->> 'x-original-filename';
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
_max_file_size INT := 5 * 1024 * 1024;
BEGIN
IF OCTET_LENGTH($1) = 0 THEN
RAISE invalid_parameter_value
USING message = 'No file data was provided';
END IF;
IF _mimetype IS NULL OR _mimetype NOT IN (
SELECT
UNNEST(_allowed_mimetypes)) THEN
RAISE invalid_parameter_value
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
END IF;
IF OCTET_LENGTH($1) > _max_file_size THEN
RAISE program_limit_exceeded
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
END IF;
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
VALUES (_website_id, $1, _mimetype, _original_filename)
RETURNING
id INTO file_id;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
-- migrate:down
DROP FUNCTION api.upload_file (BYTEA);
CREATE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
AS $$
DECLARE
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
_mimetype TEXT := _headers ->> 'x-mimetype';
_original_filename TEXT := _headers ->> 'x-original-filename';
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
_max_file_size INT := 5 * 1024 * 1024;
BEGIN
IF OCTET_LENGTH($1) = 0 THEN
RAISE invalid_parameter_value
USING message = 'No file data was provided';
END IF;
IF _mimetype IS NULL OR _mimetype NOT IN (
SELECT
UNNEST(_allowed_mimetypes)) THEN
RAISE invalid_parameter_value
USING message = 'Invalid MIME type. Allowed types are: png, svg, jpg, webp';
END IF;
IF OCTET_LENGTH($1) > _max_file_size THEN
RAISE program_limit_exceeded
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
END IF;
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
VALUES (_website_id, $1, _mimetype, _original_filename)
RETURNING
id INTO file_id;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;

View File

@@ -1,120 +0,0 @@
-- migrate:up
CREATE TABLE internal.docs_category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
category_weight INTEGER CHECK (category_weight >= 0) NOT NULL,
UNIQUE (website_id, category_name),
UNIQUE (website_id, category_weight)
);
ALTER TABLE internal.website
ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE internal.article
ADD COLUMN category UUID REFERENCES internal.docs_category (id) ON DELETE SET NULL;
ALTER TABLE internal.article
ALTER COLUMN user_id SET DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
ALTER TABLE internal.docs_category ENABLE ROW LEVEL SECURITY;
CREATE POLICY view_categories ON internal.docs_category
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
CREATE POLICY update_category ON internal.docs_category
FOR UPDATE
USING (internal.user_has_website_access (website_id, 20));
CREATE POLICY delete_category ON internal.docs_category
FOR DELETE
USING (internal.user_has_website_access (website_id, 20, article_user_id => user_id));
CREATE POLICY insert_category ON internal.docs_category
FOR INSERT
WITH CHECK (internal.user_has_website_access (website_id, 20));
CREATE VIEW api.docs_category WITH ( security_invoker = ON
) AS
SELECT
id,
website_id,
user_id,
category_name,
category_weight
FROM
internal.docs_category;
CREATE OR REPLACE VIEW api.article WITH ( security_invoker = ON
) AS
SELECT
id,
website_id,
user_id,
title,
meta_description,
meta_author,
cover_image,
publication_date,
main_content,
created_at,
last_modified_at,
last_modified_by,
title_description_search,
category -- New column
FROM
internal.article;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.docs_category TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.docs_category TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
-- migrate:down
DROP POLICY view_categories ON internal.docs_category;
DROP POLICY update_category ON internal.docs_category;
DROP POLICY delete_category ON internal.docs_category;
DROP POLICY insert_category ON internal.docs_category;
DROP VIEW api.article;
CREATE VIEW api.article WITH ( security_invoker = ON
) AS
SELECT
id,
website_id,
user_id,
title,
meta_description,
meta_author,
cover_image,
publication_date,
main_content,
created_at,
last_modified_at,
last_modified_by,
title_description_search
FROM
internal.article;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
DROP VIEW api.docs_category;
ALTER TABLE internal.article
DROP COLUMN category;
DROP TABLE internal.docs_category;
ALTER TABLE internal.website
DROP COLUMN is_published;
ALTER TABLE internal.article
ALTER COLUMN user_id DROP DEFAULT;

View File

@@ -1,108 +0,0 @@
-- migrate:up
CREATE OR REPLACE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles,
CASE WHEN w.content_type = 'Docs' THEN
(
SELECT
JSON_OBJECT_AGG(
COALESCE(
category_name, 'Uncategorized'
), articles
)
FROM (
SELECT
dc.category_name,
dc.category_weight AS category_weight,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
) AS articles
FROM
internal.article a
LEFT JOIN internal.docs_category dc ON a.category = dc.id
WHERE
a.website_id = w.id
GROUP BY
dc.id,
dc.category_name,
dc.category_weight
ORDER BY
category_weight DESC
) AS categorized_articles)
ELSE
NULL
END AS categorized_articles
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;
-- migrate:down
DROP VIEW api.website_overview;
CREATE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;

View File

@@ -1,56 +0,0 @@
-- migrate:up
ALTER TABLE internal.article
ADD COLUMN article_weight INTEGER CHECK (article_weight IS NULL
OR article_weight >= 0);
CREATE OR REPLACE VIEW api.article WITH ( security_invoker = ON
) AS
SELECT
id,
website_id,
user_id,
title,
meta_description,
meta_author,
cover_image,
publication_date,
main_content,
created_at,
last_modified_at,
last_modified_by,
title_description_search,
category,
article_weight -- New column
FROM
internal.article;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
-- migrate:down
DROP VIEW api.article;
CREATE VIEW api.article WITH ( security_invoker = ON
) AS
SELECT
id,
website_id,
user_id,
title,
meta_description,
meta_author,
cover_image,
publication_date,
main_content,
created_at,
last_modified_at,
last_modified_by,
title_description_search,
category
FROM
internal.article;
ALTER TABLE internal.article
DROP COLUMN article_weight;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;

View File

@@ -1,140 +0,0 @@
-- migrate:up
CREATE OR REPLACE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles,
CASE WHEN w.content_type = 'Docs' THEN
(
SELECT
JSON_OBJECT_AGG(
COALESCE(
category_name, 'Uncategorized'
), articles
)
FROM (
SELECT
dc.category_name,
dc.category_weight AS category_weight,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
) AS articles
FROM
internal.article a
LEFT JOIN internal.docs_category dc ON a.category = dc.id
WHERE
a.website_id = w.id
GROUP BY
dc.id,
dc.category_name,
dc.category_weight
ORDER BY
category_weight DESC NULLS LAST
) AS categorized_articles)
ELSE
NULL
END AS categorized_articles
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;
-- migrate:down
DROP VIEW api.website_overview;
CREATE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles,
CASE WHEN w.content_type = 'Docs' THEN
(
SELECT
JSON_OBJECT_AGG(
COALESCE(
category_name, 'Uncategorized'
), articles
)
FROM (
SELECT
dc.category_name,
dc.category_weight AS category_weight,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
) AS articles
FROM
internal.article a
LEFT JOIN internal.docs_category dc ON a.category = dc.id
WHERE
a.website_id = w.id
GROUP BY
dc.id,
dc.category_name,
dc.category_weight
ORDER BY
category_weight DESC
) AS categorized_articles)
ELSE
NULL
END AS categorized_articles
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;

View File

@@ -1,140 +0,0 @@
-- migrate:up
CREATE OR REPLACE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles,
CASE WHEN w.content_type = 'Docs' THEN
(
SELECT
JSON_OBJECT_AGG(
COALESCE(
category_name, 'Uncategorized'
), articles
)
FROM (
SELECT
dc.category_name,
dc.category_weight AS category_weight,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
) ORDER BY a.article_weight DESC NULLS LAST
) AS articles
FROM
internal.article a
LEFT JOIN internal.docs_category dc ON a.category = dc.id
WHERE
a.website_id = w.id
GROUP BY
dc.id,
dc.category_name,
dc.category_weight
ORDER BY
category_weight DESC NULLS LAST
) AS categorized_articles)
ELSE
NULL
END AS categorized_articles
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;
-- migrate:down
DROP VIEW api.website_overview;
CREATE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles,
CASE WHEN w.content_type = 'Docs' THEN
(
SELECT
JSON_OBJECT_AGG(
COALESCE(
category_name, 'Uncategorized'
), articles
)
FROM (
SELECT
dc.category_name,
dc.category_weight AS category_weight,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
) AS articles
FROM
internal.article a
LEFT JOIN internal.docs_category dc ON a.category = dc.id
WHERE
a.website_id = w.id
GROUP BY
dc.id,
dc.category_name,
dc.category_weight
ORDER BY
category_weight DESC NULLS LAST
) AS categorized_articles)
ELSE
NULL
END AS categorized_articles
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;

View File

@@ -1,15 +0,0 @@
-- migrate:up
ALTER TABLE internal.article
ALTER COLUMN publication_date DROP NOT NULL;
-- migrate:down
UPDATE
internal.article
SET
publication_date = CURRENT_DATE
WHERE
publication_date IS NULL;
ALTER TABLE internal.article
ALTER COLUMN publication_date SET NOT NULL;

View File

@@ -1,37 +0,0 @@
-- migrate:up
CREATE OR REPLACE VIEW api.website WITH ( security_invoker = ON
) AS
SELECT
id,
user_id,
content_type,
title,
created_at,
last_modified_at,
last_modified_by,
title_search,
is_published -- New column
FROM
internal.website;
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
-- migrate:down
DROP VIEW api.website;
CREATE OR REPLACE VIEW api.website WITH ( security_invoker = ON
) AS
SELECT
id,
user_id,
content_type,
title,
created_at,
last_modified_at,
last_modified_by,
title_search
FROM
internal.website;
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;

View File

@@ -1,76 +0,0 @@
-- migrate:up
CREATE OR REPLACE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
AS $$
DECLARE
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
_mimetype TEXT := _headers ->> 'x-mimetype';
_original_filename TEXT := _headers ->> 'x-original-filename';
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
_max_file_size INT := 5 * 1024 * 1024;
_has_access BOOLEAN;
BEGIN
_has_access = internal.user_has_website_access (_website_id, 20);
IF OCTET_LENGTH($1) = 0 THEN
RAISE invalid_parameter_value
USING message = 'No file data was provided';
END IF;
IF _mimetype IS NULL OR _mimetype NOT IN (
SELECT
UNNEST(_allowed_mimetypes)) THEN
RAISE invalid_parameter_value
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
END IF;
IF OCTET_LENGTH($1) > _max_file_size THEN
RAISE program_limit_exceeded
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
END IF;
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
VALUES (_website_id, $1, _mimetype, _original_filename)
RETURNING
id INTO file_id;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
-- migrate:down
DROP FUNCTION api.upload_file (BYTEA);
CREATE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
AS $$
DECLARE
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
_mimetype TEXT := _headers ->> 'x-mimetype';
_original_filename TEXT := _headers ->> 'x-original-filename';
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
_max_file_size INT := 5 * 1024 * 1024;
BEGIN
IF OCTET_LENGTH($1) = 0 THEN
RAISE invalid_parameter_value
USING message = 'No file data was provided';
END IF;
IF _mimetype IS NULL OR _mimetype NOT IN (
SELECT
UNNEST(_allowed_mimetypes)) THEN
RAISE invalid_parameter_value
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
END IF;
IF OCTET_LENGTH($1) > _max_file_size THEN
RAISE program_limit_exceeded
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
END IF;
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
VALUES (_website_id, $1, _mimetype, _original_filename)
RETURNING
id INTO file_id;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;

View File

@@ -1,55 +0,0 @@
-- migrate:up
CREATE TABLE internal.legal_information (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE VIEW api.legal_information WITH ( security_invoker = ON
) AS
SELECT
website_id,
main_content,
last_modified_at,
last_modified_by
FROM
internal.legal_information;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.legal_information TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.legal_information TO authenticated_user;
ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
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));
-- migrate:down
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;
ALTER TABLE internal.legal_information DISABLE ROW LEVEL SECURITY;
DROP VIEW api.legal_information;
DROP TABLE internal.legal_information;

View File

@@ -1,142 +0,0 @@
-- migrate:up
CREATE OR REPLACE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles,
CASE WHEN w.content_type = 'Docs' THEN
(
SELECT
JSON_OBJECT_AGG(
COALESCE(
category_name, 'Uncategorized'
), articles
)
FROM (
SELECT
dc.category_name,
dc.category_weight AS category_weight,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
) ORDER BY a.article_weight DESC NULLS LAST
) AS articles
FROM
internal.article a
LEFT JOIN internal.docs_category dc ON a.category = dc.id
WHERE
a.website_id = w.id
GROUP BY
dc.id,
dc.category_name,
dc.category_weight
ORDER BY
category_weight DESC NULLS LAST
) AS categorized_articles)
ELSE
NULL
END AS categorized_articles,
li.main_content legal_information_main_content
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id
LEFT JOIN internal.legal_information li ON w.id = li.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;
-- migrate:down
DROP VIEW api.website_overview;
CREATE VIEW api.website_overview WITH ( security_invoker = ON
) AS
SELECT
w.id,
w.user_id,
w.content_type,
w.title,
s.accent_color_light_theme,
s.accent_color_dark_theme,
s.favicon_image,
h.logo_type,
h.logo_text,
h.logo_image,
ho.main_content,
f.additional_text,
(
SELECT
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
)
)
FROM
internal.article a
WHERE
a.website_id = w.id
) AS articles,
CASE WHEN w.content_type = 'Docs' THEN
(
SELECT
JSON_OBJECT_AGG(
COALESCE(
category_name, 'Uncategorized'
), articles
)
FROM (
SELECT
dc.category_name,
dc.category_weight AS category_weight,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
) ORDER BY a.article_weight DESC NULLS LAST
) AS articles
FROM
internal.article a
LEFT JOIN internal.docs_category dc ON a.category = dc.id
WHERE
a.website_id = w.id
GROUP BY
dc.id,
dc.category_name,
dc.category_weight
ORDER BY
category_weight DESC NULLS LAST
) AS categorized_articles)
ELSE
NULL
END AS categorized_articles
FROM
internal.website w
JOIN internal.settings s ON w.id = s.website_id
JOIN internal.header h ON w.id = h.website_id
JOIN internal.home ho ON w.id = ho.website_id
JOIN internal.footer f ON w.id = f.website_id;
GRANT SELECT ON api.website_overview TO authenticated_user;

View File

@@ -28,6 +28,7 @@
"eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "2.43.0",
"globals": "15.9.0",
"pg-to-ts": "4.1.1",
"prettier": "3.3.3",
"prettier-plugin-svelte": "3.2.6",
"svelte": "5.0.0-next.220",
@@ -1631,6 +1632,16 @@
"dequal": "^2.0.3"
}
},
"node_modules/assert-options": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.8.0.tgz",
"integrity": "sha512-qSELrEaEz4sGwTs4Qh+swQkjiHAysC4rot21+jzXU86dJzNG+FDqBzyS3ohSoTRf4ZLA3FSwxQdiuNl5NXUtvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1700,6 +1711,16 @@
"node": ">=8.0.0"
}
},
"node_modules/buffer-writer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
@@ -1781,6 +1802,100 @@
"fsevents": "~2.3.2"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1813,6 +1928,20 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/commandpost": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/commandpost/-/commandpost-1.4.0.tgz",
"integrity": "sha512-aE2Y4MTFJ870NuB/+2z1cXBhSBBzRydVVjzhFC4gtenEhpnj15yu0qptWGJsO9YGrcPZ3ezX8AWb1VA391MKpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -1985,6 +2114,43 @@
"dev": true,
"license": "MIT"
},
"node_modules/editorconfig": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz",
"integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^2.19.0",
"lru-cache": "^4.1.5",
"semver": "^5.6.0",
"sigmund": "^1.0.1"
},
"bin": {
"editorconfig": "bin/editorconfig"
}
},
"node_modules/editorconfig/node_modules/lru-cache": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
"dev": true,
"license": "ISC",
"dependencies": {
"pseudomap": "^1.0.2",
"yallist": "^2.1.2"
}
},
"node_modules/editorconfig/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -2050,6 +2216,16 @@
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2541,6 +2717,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/github-slugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
@@ -3077,6 +3263,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -3361,6 +3554,13 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==",
"dev": true,
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3440,6 +3640,142 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
"integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
"pg-connection-string": "^2.5.0",
"pg-pool": "^3.5.2",
"pg-protocol": "^1.5.0",
"pg-types": "^2.1.0",
"pgpass": "1.x"
},
"engines": {
"node": ">= 8.0.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-connection-string": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz",
"integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-minify": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.6.2.tgz",
"integrity": "sha512-1KdmFGGTP6jplJoI8MfvRlfvMiyBivMRP7/ffh4a11RUFJ7kC2J0ZHlipoKiH/1hz+DVgceon9U2qbaHpPeyPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/pg-pool": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz",
"integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-promise": {
"version": "10.15.4",
"resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.15.4.tgz",
"integrity": "sha512-BKlHCMCdNUmF6gagVbehRWSEiVcZzPVltEx14OJExR9Iz9/1R6KETDWLLGv2l6yRqYFnEZZy1VDjRhArzeIGrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assert-options": "0.8.0",
"pg": "8.8.0",
"pg-minify": "1.6.2",
"spex": "3.2.0"
},
"engines": {
"node": ">=12.0"
}
},
"node_modules/pg-protocol": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz",
"integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-to-ts": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/pg-to-ts/-/pg-to-ts-4.1.1.tgz",
"integrity": "sha512-wc/ZXMMQrxu42mnl6eEdMgT31S9rvA/Oh9I9PchovUwoJLzEg0osGQjxiQOLjAdz3Ti45o749XREJ2s+xncZ6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"pg-promise": "^10.11.1",
"typescript-formatter": "^7.0.1",
"yargs": "^17.3.1"
},
"bin": {
"pg-to-ts": "dist/cli.js"
},
"engines": {
"node": ">=8.15.1"
},
"peerDependencies": {
"typescript": ">=4.1"
}
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
@@ -3624,6 +3960,49 @@
"node": ">=4"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -3661,6 +4040,13 @@
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
"integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==",
"dev": true,
"license": "ISC"
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@@ -3716,6 +4102,16 @@
"node": ">=8.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -3974,6 +4370,13 @@
"node": ">=8"
}
},
"node_modules/sigmund": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz",
"integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -4028,6 +4431,26 @@
"node": ">=0.10.0"
}
},
"node_modules/spex": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/spex/-/spex-3.2.0.tgz",
"integrity": "sha512-9srjJM7NaymrpwMHvSmpDeIK5GoRMX/Tq0E8aOlDPS54dDnDUIp30DrP9SphMPEETDLzEM9+4qo+KipmbtPecg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4.5"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -4517,6 +4940,26 @@
}
}
},
"node_modules/typescript-formatter": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/typescript-formatter/-/typescript-formatter-7.2.2.tgz",
"integrity": "sha512-V7vfI9XArVhriOTYHPzMU2WUnm5IMdu9X/CPxs8mIMGxmTBFpDABlbkBka64PZJ9/xgQeRpK8KzzAG4MPzxBDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"commandpost": "^1.0.0",
"editorconfig": "^0.15.0"
},
"bin": {
"tsfmt": "bin/tsfmt"
},
"engines": {
"node": ">= 4.2.0"
},
"peerDependencies": {
"typescript": "^2.1.6 || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev"
}
},
"node_modules/undici-types": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz",
@@ -4857,6 +5300,33 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
@@ -4867,6 +5337,80 @@
"node": ">= 6"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -10,7 +10,8 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"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"
},
"devDependencies": {
"@playwright/test": "1.40.0",
@@ -26,6 +27,7 @@
"eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "2.43.0",
"globals": "15.9.0",
"pg-to-ts": "4.1.1",
"prettier": "3.3.3",
"prettier-plugin-svelte": "3.2.6",
"svelte": "5.0.0-next.220",

View File

@@ -1,9 +1,6 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
interface User {
id: string;
username: string;
}
import type { User } from "$lib/db-schema";
declare global {
namespace App {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
const { date }: { date: string } = $props();
const { date }: { date: Date } = $props();
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
@@ -11,6 +11,6 @@
};
</script>
<time datetime={new Date(date).toLocaleString("sv").replace(" ", "T")}>
{new Date(date).toLocaleString("en-us", { ...options })}
<time datetime={date.toLocaleString("sv").replace(" ", "T")}>
{date.toLocaleString("en-us", { ...options })}
</time>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
const { success, message }: { success: boolean | undefined; message: string | undefined } =
$props();
const { success, message }: { success?: boolean; message?: string } = $props();
</script>
{#if success}

View File

@@ -0,0 +1,496 @@
/* tslint:disable */
/* eslint-disable */
/**
* AUTO-GENERATED FILE - DO NOT EDIT!
*
* This file was automatically generated by pg-to-ts v.4.1.1
* $ pg-to-ts generate -c postgres://username:password@localhost:15432/archtika -t article -t change_log -t collab -t docs_category -t footer -t header -t home -t legal_information -t media -t settings -t user -t website -s internal
*
*/
export type Json = unknown;
// Table article
export interface Article {
id: string;
website_id: string;
user_id: string | null;
title: string;
meta_description: string | null;
meta_author: string | null;
cover_image: string | null;
publication_date: Date | null;
main_content: string | null;
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
title_description_search: any | null;
category: string | null;
article_weight: number | null;
}
export interface ArticleInput {
id?: string;
website_id: string;
user_id?: string | null;
title: string;
meta_description?: string | null;
meta_author?: string | null;
cover_image?: string | null;
publication_date?: Date | null;
main_content?: string | null;
created_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
title_description_search?: any | null;
category?: string | null;
article_weight?: number | null;
}
const article = {
tableName: "article",
columns: [
"id",
"website_id",
"user_id",
"title",
"meta_description",
"meta_author",
"cover_image",
"publication_date",
"main_content",
"created_at",
"last_modified_at",
"last_modified_by",
"title_description_search",
"category",
"article_weight"
],
requiredForInsert: ["website_id", "title"],
primaryKey: "id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
user_id: { table: "user", column: "id", $type: null as unknown as User },
cover_image: { table: "media", column: "id", $type: null as unknown as Media },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User },
category: { table: "docs_category", column: "id", $type: null as unknown as DocsCategory }
},
$type: null as unknown as Article,
$input: null as unknown as ArticleInput
} as const;
// Table change_log
export interface ChangeLog {
website_id: string;
user_id: string;
change_summary: string;
previous_value: Json | null;
new_value: Json | null;
timestamp: Date;
}
export interface ChangeLogInput {
website_id: string;
user_id?: string;
change_summary: string;
previous_value?: Json | null;
new_value?: Json | null;
timestamp?: Date;
}
const change_log = {
tableName: "change_log",
columns: ["website_id", "user_id", "change_summary", "previous_value", "new_value", "timestamp"],
requiredForInsert: ["website_id", "change_summary"],
primaryKey: "website_id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
user_id: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as ChangeLog,
$input: null as unknown as ChangeLogInput
} as const;
// Table collab
export interface Collab {
website_id: string;
user_id: string;
permission_level: number;
added_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface CollabInput {
website_id: string;
user_id: string;
permission_level?: number;
added_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const collab = {
tableName: "collab",
columns: [
"website_id",
"user_id",
"permission_level",
"added_at",
"last_modified_at",
"last_modified_by"
],
requiredForInsert: ["website_id", "user_id"],
primaryKey: "website_id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
user_id: { table: "user", column: "id", $type: null as unknown as User },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as Collab,
$input: null as unknown as CollabInput
} as const;
// Table docs_category
export interface DocsCategory {
id: string;
website_id: string;
user_id: string | null;
category_name: string;
category_weight: number;
}
export interface DocsCategoryInput {
id?: string;
website_id: string;
user_id?: string | null;
category_name: string;
category_weight: number;
}
const docs_category = {
tableName: "docs_category",
columns: ["id", "website_id", "user_id", "category_name", "category_weight"],
requiredForInsert: ["website_id", "category_name", "category_weight"],
primaryKey: "id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
user_id: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as DocsCategory,
$input: null as unknown as DocsCategoryInput
} as const;
// Table footer
export interface Footer {
website_id: string;
additional_text: string;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface FooterInput {
website_id: string;
additional_text: string;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const footer = {
tableName: "footer",
columns: ["website_id", "additional_text", "last_modified_at", "last_modified_by"],
requiredForInsert: ["website_id", "additional_text"],
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 Footer,
$input: null as unknown as FooterInput
} as const;
// Table header
export interface Header {
website_id: string;
logo_type: string;
logo_text: string | null;
logo_image: string | null;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface HeaderInput {
website_id: string;
logo_type?: string;
logo_text?: string | null;
logo_image?: string | null;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const header = {
tableName: "header",
columns: [
"website_id",
"logo_type",
"logo_text",
"logo_image",
"last_modified_at",
"last_modified_by"
],
requiredForInsert: ["website_id"],
primaryKey: "website_id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
logo_image: { table: "media", column: "id", $type: null as unknown as Media },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as Header,
$input: null as unknown as HeaderInput
} as const;
// Table home
export interface Home {
website_id: string;
main_content: string;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface HomeInput {
website_id: string;
main_content: string;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const home = {
tableName: "home",
columns: ["website_id", "main_content", "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 Home,
$input: null as unknown as HomeInput
} as const;
// Table legal_information
export interface LegalInformation {
website_id: string;
main_content: string;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface LegalInformationInput {
website_id: string;
main_content: string;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const legal_information = {
tableName: "legal_information",
columns: ["website_id", "main_content", "last_modified_at", "last_modified_by"],
requiredForInsert: ["website_id", "main_content"],
primaryKey: "website_id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as LegalInformation,
$input: null as unknown as LegalInformationInput
} as const;
// Table media
export interface Media {
id: string;
website_id: string;
user_id: string;
blob: string;
mimetype: string;
original_name: string;
created_at: Date;
}
export interface MediaInput {
id?: string;
website_id: string;
user_id?: string;
blob: string;
mimetype: string;
original_name: string;
created_at?: Date;
}
const media = {
tableName: "media",
columns: ["id", "website_id", "user_id", "blob", "mimetype", "original_name", "created_at"],
requiredForInsert: ["website_id", "blob", "mimetype", "original_name"],
primaryKey: "id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
user_id: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as Media,
$input: null as unknown as MediaInput
} as const;
// Table settings
export interface Settings {
website_id: string;
accent_color_light_theme: string;
accent_color_dark_theme: string;
favicon_image: string | null;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface SettingsInput {
website_id: string;
accent_color_light_theme?: string;
accent_color_dark_theme?: string;
favicon_image?: string | null;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const settings = {
tableName: "settings",
columns: [
"website_id",
"accent_color_light_theme",
"accent_color_dark_theme",
"favicon_image",
"last_modified_at",
"last_modified_by"
],
requiredForInsert: ["website_id"],
primaryKey: "website_id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
favicon_image: { table: "media", column: "id", $type: null as unknown as Media },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as Settings,
$input: null as unknown as SettingsInput
} as const;
// Table user
export interface User {
id: string;
username: string;
password_hash: string;
role: string;
}
export interface UserInput {
id?: string;
username: string;
password_hash: string;
role?: string;
}
const user = {
tableName: "user",
columns: ["id", "username", "password_hash", "role"],
requiredForInsert: ["username", "password_hash"],
primaryKey: "id",
foreignKeys: {},
$type: null as unknown as User,
$input: null as unknown as UserInput
} as const;
// Table website
export interface Website {
id: string;
user_id: string;
content_type: string;
title: string;
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
title_search: any | null;
is_published: boolean;
}
export interface WebsiteInput {
id?: string;
user_id?: string;
content_type: string;
title: string;
created_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
title_search?: any | null;
is_published?: boolean;
}
const website = {
tableName: "website",
columns: [
"id",
"user_id",
"content_type",
"title",
"created_at",
"last_modified_at",
"last_modified_by",
"title_search",
"is_published"
],
requiredForInsert: ["content_type", "title"],
primaryKey: "id",
foreignKeys: {
user_id: { table: "user", column: "id", $type: null as unknown as User },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as Website,
$input: null as unknown as WebsiteInput
} as const;
export interface TableTypes {
article: {
select: Article;
input: ArticleInput;
};
change_log: {
select: ChangeLog;
input: ChangeLogInput;
};
collab: {
select: Collab;
input: CollabInput;
};
docs_category: {
select: DocsCategory;
input: DocsCategoryInput;
};
footer: {
select: Footer;
input: FooterInput;
};
header: {
select: Header;
input: HeaderInput;
};
home: {
select: Home;
input: HomeInput;
};
legal_information: {
select: LegalInformation;
input: LegalInformationInput;
};
media: {
select: Media;
input: MediaInput;
};
settings: {
select: Settings;
input: SettingsInput;
};
user: {
select: User;
input: UserInput;
};
website: {
select: Website;
input: WebsiteInput;
};
}
export const tables = {
article,
change_log,
collab,
docs_category,
footer,
header,
home,
legal_information,
media,
settings,
user,
website
};

View File

@@ -2,52 +2,44 @@
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 {
favicon,
title,
logoType,
logo,
mainContent,
coverImage,
publicationDate,
footerAdditionalText,
metaDescription
}: {
favicon: string;
title: string;
logoType: "text" | "image";
logo: string;
mainContent: string;
coverImage: string;
publicationDate: string;
footerAdditionalText: string;
metaDescription: string;
} = $props();
websiteOverview,
article,
apiUrl
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string } = $props();
</script>
<Head {title} {favicon} nestingLevel={1} {metaDescription} />
<Head
{websiteOverview}
nestingLevel={1}
{apiUrl}
title={article.title}
metaDescription={article.meta_description}
/>
<Nav {logoType} {logo} isIndexPage={false} />
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={false} {apiUrl} />
<header>
<div class="container">
<hgroup>
<p>{publicationDate}</p>
<h1>{title}</h1>
<p>{article.publication_date}</p>
<h1>{article.title}</h1>
</hgroup>
{#if coverImage}
<img src={coverImage} alt="" />
{#if article.cover_image}
<img src="{apiUrl}/rpc/retrieve_file?id={article.cover_image}" alt="" />
{/if}
</div>
</header>
{#if mainContent}
{#if article.main_content}
<main>
<div class="container">
{@html mainContent}
{@html md(article.main_content)}
</div>
</main>
{/if}
<Footer text={footerAdditionalText} isIndexPage={false} />
<Footer {websiteOverview} isIndexPage={false} />

View File

@@ -2,47 +2,46 @@
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 {
favicon,
title,
logoType,
logo,
mainContent,
articles,
footerAdditionalText
}: {
favicon: string;
title: string;
logoType: "text" | "image";
logo: string;
mainContent: string;
articles: { title: string; publication_date: string; meta_description: string }[];
footerAdditionalText: string;
} = $props();
websiteOverview,
apiUrl,
isLegalPage
}: { websiteOverview: WebsiteOverview; apiUrl: string; isLegalPage: boolean } = $props();
</script>
<Head {title} {favicon} />
<Head
{websiteOverview}
nestingLevel={0}
{apiUrl}
title={isLegalPage ? "Legal information" : websiteOverview.title}
/>
<Nav {logoType} {logo} />
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={true} {apiUrl} />
<header>
<div class="container">
<h1>{title}</h1>
<h1>{isLegalPage ? "Legal information" : websiteOverview.title}</h1>
</div>
</header>
<main>
<div class="container">
{@html mainContent}
{#if articles.length > 0}
{@html md(
isLegalPage
? (websiteOverview.legal_information?.main_content ?? "")
: websiteOverview.home.main_content,
false
)}
{#if websiteOverview.article.length > 0 && !isLegalPage}
<section class="articles" id="articles">
<h2>
<a href="#articles">Articles</a>
</h2>
<ul class="unpadded">
{#each articles as article}
{#each websiteOverview.article as article}
{@const articleFileName = article.title.toLowerCase().split(" ").join("-")}
<li>
<p>{article.publication_date}</p>
@@ -62,4 +61,4 @@
</div>
</main>
<Footer text={footerAdditionalText} />
<Footer {websiteOverview} isIndexPage={true} />

View File

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

View File

@@ -1,13 +1,17 @@
<script lang="ts">
import type { WebsiteOverview } from "../../utils";
const {
websiteOverview,
nestingLevel,
apiUrl,
title,
favicon,
nestingLevel = 0,
metaDescription = null
metaDescription
}: {
websiteOverview: WebsiteOverview;
nestingLevel: number;
apiUrl: string;
title: string;
favicon: string;
nestingLevel?: number;
metaDescription?: string | null;
} = $props();
</script>
@@ -19,8 +23,11 @@
<title>{title}</title>
<meta name="description" content={metaDescription ?? title} />
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}styles.css`} />
{#if favicon}
<link rel="icon" href={favicon} />
{#if websiteOverview.settings.favicon_image}
<link
rel="icon"
href="{apiUrl}/rpc/retrieve_file?id={websiteOverview.settings.favicon_image}"
/>
{/if}
</head>
</svelte:head>

View File

@@ -1,17 +1,36 @@
<script lang="ts">
import type { WebsiteOverview } from "../../utils";
import type { Article } from "../../db-schema";
const {
logoType,
logo,
isDocsTemplate = false,
categorizedArticles = {},
isIndexPage = true
websiteOverview,
isDocsTemplate,
isIndexPage,
apiUrl
}: {
logoType: "text" | "image";
logo: string;
isDocsTemplate?: boolean;
categorizedArticles?: { [key: string]: { title: string }[] };
isIndexPage?: boolean;
websiteOverview: WebsiteOverview;
isDocsTemplate: boolean;
isIndexPage: boolean;
apiUrl: string;
} = $props();
const categorizedArticles = Object.fromEntries(
Object.entries(
Object.groupBy(
websiteOverview.article.sort((a, b) => (b.article_weight ?? 0) - (a.article_weight ?? 0)),
(article) => article.docs_category?.category_name ?? "Uncategorized"
)
).sort(([a], [b]) =>
a === "Uncategorized"
? 1
: b === "Uncategorized"
? -1
: (websiteOverview.article.find((art) => art.docs_category?.category_name === b)
?.docs_category?.category_weight ?? 0) -
(websiteOverview.article.find((art) => art.docs_category?.category_name === a)
?.docs_category?.category_weight ?? 0)
)
) as { [key: string]: Article[] };
</script>
<nav>
@@ -53,10 +72,15 @@
</section>
{/if}
<a href={isIndexPage ? "." : ".."}>
{#if logoType === "text"}
<strong>{logo}</strong>
{#if websiteOverview.header.logo_type === "text"}
<strong>{websiteOverview.header.logo_text}</strong>
{:else}
<img src={logo} width="24" height="24" alt="" />
<img
src="{apiUrl}/rpc/retrieve_file?id={websiteOverview.header.logo_image}"
width="24"
height="24"
alt=""
/>
{/if}
</a>
</div>

View File

@@ -2,44 +2,38 @@
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 {
favicon,
title,
logoType,
logo,
mainContent,
categorizedArticles,
footerAdditionalText,
metaDescription
}: {
favicon: string;
title: string;
logoType: "text" | "image";
logo: string;
mainContent: string;
categorizedArticles: { [key: string]: { title: string }[] };
footerAdditionalText: string;
metaDescription: string;
} = $props();
websiteOverview,
article,
apiUrl
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string } = $props();
</script>
<Head {title} {favicon} nestingLevel={1} {metaDescription} />
<Head
{websiteOverview}
nestingLevel={1}
{apiUrl}
title={article.title}
metaDescription={article.meta_description}
/>
<Nav {logoType} {logo} isDocsTemplate={true} {categorizedArticles} isIndexPage={false} />
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={false} {apiUrl} />
<header>
<div class="container">
<h1>{title}</h1>
<h1>{article.title}</h1>
</div>
</header>
{#if mainContent}
{#if article.main_content}
<main>
<div class="container">
{@html mainContent}
{@html md(article.main_content)}
</div>
</main>
{/if}
<Footer text={footerAdditionalText} isIndexPage={false} />
<Footer {websiteOverview} isIndexPage={false} />

View File

@@ -2,40 +2,39 @@
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 {
favicon,
title,
logoType,
logo,
mainContent,
categorizedArticles,
footerAdditionalText
}: {
favicon: string;
title: string;
logoType: "text" | "image";
logo: string;
mainContent: string;
categorizedArticles: { [key: string]: { title: string }[] };
footerAdditionalText: string;
} = $props();
websiteOverview,
apiUrl,
isLegalPage
}: { websiteOverview: WebsiteOverview; apiUrl: string; isLegalPage: boolean } = $props();
</script>
<Head {title} {favicon} />
<Head
{websiteOverview}
nestingLevel={0}
{apiUrl}
title={isLegalPage ? "Legal information" : websiteOverview.title}
/>
<Nav {logoType} {logo} isDocsTemplate={true} {categorizedArticles} />
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={true} {apiUrl} />
<header>
<div class="container">
<h1>{title}</h1>
<h1>{isLegalPage ? "Legal information" : websiteOverview.title}</h1>
</div>
</header>
<main>
<div class="container">
{@html mainContent}
{@html md(
isLegalPage
? (websiteOverview.legal_information?.main_content ?? "")
: websiteOverview.home.main_content,
false
)}
</div>
</main>
<Footer text={footerAdditionalText} />
<Footer {websiteOverview} isIndexPage={true} />

View File

@@ -5,6 +5,16 @@ import hljs from "highlight.js";
import GithubSlugger from "github-slugger";
import DOMPurify from "isomorphic-dompurify";
import { applyAction, deserialize } from "$app/forms";
import type {
Website,
Settings,
Header,
Home,
Footer,
Article,
DocsCategory,
LegalInformation
} from "$lib/db-schema";
export const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/svg+xml", "image/webp"];
@@ -189,3 +199,12 @@ export const handleImagePaste = async (event: ClipboardEvent, API_BASE_PREFIX: s
return "";
}
};
export interface WebsiteOverview extends Website {
settings: Settings;
header: Header;
home: Home;
footer: Footer;
article: (Article & { docs_category: DocsCategory | null })[];
legal_information?: LegalInformation;
}

View File

@@ -10,7 +10,7 @@ export const actions: Actions = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: data.get("username"),
password: data.get("password")
pass: data.get("password")
})
});

View File

@@ -10,7 +10,7 @@ export const actions: Actions = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: data.get("username"),
password: data.get("password")
pass: data.get("password")
})
});

View File

@@ -2,6 +2,7 @@ import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import type { Website, WebsiteInput } from "$lib/db-schema";
export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => {
const searchQuery = url.searchParams.get("website_search_query");
@@ -47,7 +48,7 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => {
}
});
const websites = await websiteData.json();
const websites: Website[] = await websiteData.json();
return {
totalWebsiteCount,
@@ -66,9 +67,9 @@ export const actions: Actions = {
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
content_type: data.get("content-type"),
title: data.get("title")
})
content_type: data.get("content-type") as string,
title: data.get("title") as string
} satisfies WebsiteInput)
});
if (!res.ok) {

View File

@@ -23,7 +23,7 @@ export const actions: Actions = {
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
password: data.get("password")
pass: data.get("password")
})
});

View File

@@ -1,6 +1,7 @@
import type { LayoutServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { error } from "@sveltejs/kit";
import type { Website, Home } from "$lib/db-schema";
export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => {
const websiteData = await fetch(`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`, {
@@ -25,8 +26,8 @@ export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => {
}
});
const website = await websiteData.json();
const home = await homeData.json();
const website: Website = await websiteData.json();
const home: Home = await homeData.json();
return {
website,

View File

@@ -1,5 +1,6 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { Settings, Header, Footer } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
const globalSettingsData = await fetch(
@@ -32,9 +33,9 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
}
});
const globalSettings = await globalSettingsData.json();
const header = await headerData.json();
const footer = await footerData.json();
const globalSettings: Settings = await globalSettingsData.json();
const header: Header = await headerData.json();
const footer: Footer = await footerData.json();
return {
globalSettings,

View File

@@ -1,5 +1,6 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { Article, ArticleInput, DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent, locals }) => {
const searchQuery = url.searchParams.get("article_search_query");
@@ -54,7 +55,7 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent
}
});
const articles = await articlesData.json();
const articles: (Article & { docs_category: DocsCategory | null })[] = await articlesData.json();
return {
totalArticleCount,
@@ -76,8 +77,8 @@ export const actions: Actions = {
},
body: JSON.stringify({
website_id: params.websiteId,
title: data.get("title")
})
title: data.get("title") as string
} satisfies ArticleInput)
});
if (!res.ok) {

View File

@@ -1,5 +1,6 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { Article, DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
const articleData = await fetch(`${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, {
@@ -22,8 +23,8 @@ export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) =
}
);
const article = await articleData.json();
const categories = await categoryData.json();
const article: Article = await articleData.json();
const categories: DocsCategory[] = await categoryData.json();
const { website } = await parent();
return { website, article, categories, API_BASE_PREFIX };

View File

@@ -1,5 +1,6 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { DocsCategory, DocsCategoryInput } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
const categoryData = await fetch(
@@ -13,7 +14,7 @@ export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) =
}
);
const categories = await categoryData.json();
const categories: DocsCategory[] = await categoryData.json();
const { website, home } = await parent();
return {
@@ -35,9 +36,9 @@ export const actions: Actions = {
},
body: JSON.stringify({
website_id: params.websiteId,
category_name: data.get("category-name"),
category_weight: data.get("category-weight")
})
category_name: data.get("category-name") as string,
category_weight: data.get("category-weight") as unknown as number
} satisfies DocsCategoryInput)
});
if (!res.ok) {

View File

@@ -1,5 +1,6 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { Collab, CollabInput, User } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) => {
const { website, home } = await parent();
@@ -15,7 +16,7 @@ export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) =
}
);
const collaborators = await collabData.json();
const collaborators: (Collab & { user: User })[] = await collabData.json();
return {
website,
@@ -37,6 +38,8 @@ export const actions: Actions = {
}
});
const user: User = await userData.json();
const res = await fetch(`${API_BASE_PREFIX}/collab`, {
method: "POST",
headers: {
@@ -45,9 +48,9 @@ export const actions: Actions = {
},
body: JSON.stringify({
website_id: params.websiteId,
user_id: (await userData.json()).id,
permission_level: data.get("permission-level")
})
user_id: user.id,
permission_level: data.get("permission-level") as unknown as number
} satisfies CollabInput)
});
if (!res.ok) {

View File

@@ -2,6 +2,7 @@ import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import type { LegalInformation, LegalInformationInput } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, fetch, params, cookies }) => {
const legalInformationData = await fetch(
@@ -16,7 +17,7 @@ export const load: PageServerLoad = async ({ parent, fetch, params, cookies }) =
}
);
const legalInformation = legalInformationData.ok ? await legalInformationData.json() : null;
const legalInformation: LegalInformation = await legalInformationData.json();
const { website } = await parent();
return {
@@ -39,8 +40,8 @@ export const actions: Actions = {
},
body: JSON.stringify({
website_id: params.websiteId,
main_content: data.get("main-content")
})
main_content: data.get("main-content") as string
} satisfies LegalInformationInput)
});
if (!res.ok) {

View File

@@ -8,7 +8,7 @@
const { data, form }: { data: PageServerData; form: ActionData } = $props();
let previewContent = $state(data.legalInformation?.main_content);
let previewContent = $state(data.legalInformation.main_content);
let mainContentTextarea: HTMLTextAreaElement;
let textareaScrollTop = $state(0);
@@ -80,14 +80,14 @@
bind:value={previewContent}
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}
required>{data.legalInformation?.main_content ?? ""}</textarea
required>{data.legalInformation.main_content ?? ""}</textarea
>
</label>
<button type="submit">Submit</button>
</form>
{#if data.legalInformation?.main_content}
{#if data.legalInformation.main_content}
<Modal id="delete-legal-information" text="Delete">
<form
action="?/deleteLegalInformation"
@@ -98,7 +98,6 @@
await update();
window.location.hash = "!";
sending = false;
previewContent = null;
};
}}
>

View File

@@ -1,6 +1,6 @@
import { readFile, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { md } from "$lib/utils";
import { type WebsiteOverview } from "$lib/utils";
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { render } from "svelte/server";
@@ -10,34 +10,9 @@ import DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
import { dev } from "$app/environment";
interface WebsiteData {
id: string;
content_type: "Blog" | "Docs";
favicon_image: string | null;
title: string;
logo_type: "text" | "image";
logo_text: string | null;
logo_image: string | null;
main_content: string;
additional_text: string;
accent_color_light_theme: string;
accent_color_dark_theme: string;
articles: {
cover_image: string | null;
title: string;
publication_date: string;
meta_description: string;
main_content: string;
}[];
categorized_articles: {
[key: string]: { title: string; publication_date: string; meta_description: string }[];
};
legal_information_main_content: string | null;
}
export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) => {
export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
const websiteOverviewData = await fetch(
`${API_BASE_PREFIX}/website_overview?id=eq.${params.websiteId}`,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`,
{
method: "GET",
headers: {
@@ -48,8 +23,7 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) =
}
);
const websiteOverview = await websiteOverviewData.json();
const { website } = await parent();
const websiteOverview: WebsiteOverview = await websiteOverviewData.json();
generateStaticFiles(websiteOverview);
@@ -70,15 +44,14 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) =
return {
websiteOverview,
websitePreviewUrl,
websiteProdUrl,
website
websiteProdUrl
};
};
export const actions: Actions = {
publishWebsite: async ({ fetch, params, cookies }) => {
const websiteOverviewData = await fetch(
`${API_BASE_PREFIX}/website_overview?id=eq.${params.websiteId}`,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`,
{
method: "GET",
headers: {
@@ -112,54 +85,9 @@ export const actions: Actions = {
}
};
const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean = true) => {
let head = "";
let body = "";
switch (websiteData.content_type) {
case "Blog":
{
({ head, body } = render(BlogIndex, {
props: {
favicon: websiteData.favicon_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
: "",
title: websiteData.title,
logoType: websiteData.logo_type,
logo:
websiteData.logo_type === "text"
? (websiteData.logo_text ?? "")
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
mainContent: md(websiteData.main_content ?? "", false),
articles: websiteData.articles ?? [],
footerAdditionalText: md(websiteData.additional_text ?? "")
}
}));
}
break;
case "Docs":
{
({ head, body } = render(DocsIndex, {
props: {
favicon: websiteData.favicon_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
: "",
title: websiteData.title,
logoType: websiteData.logo_type,
logo:
websiteData.logo_type === "text"
? (websiteData.logo_text ?? "")
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
mainContent: md(websiteData.main_content ?? "", false),
categorizedArticles: websiteData.categorized_articles ?? [],
footerAdditionalText: md(websiteData.additional_text ?? "")
}
}));
}
break;
}
const indexFileContents = `
const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: boolean = true) => {
const fileContents = (head: string, body: string) => {
return `
<!DOCTYPE html>
<html lang="en">
<head>
@@ -169,6 +97,15 @@ const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean
${body}
</body>
</html>`;
};
const { head, body } = render(websiteData.content_type === "Blog" ? BlogIndex : DocsIndex, {
props: {
websiteOverview: websiteData,
apiUrl: API_BASE_PREFIX,
isLegalPage: false
}
});
let uploadDir = "";
@@ -179,138 +116,38 @@ const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean
}
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, "index.html"), indexFileContents);
await writeFile(join(uploadDir, "index.html"), fileContents(head, body));
await mkdir(join(uploadDir, "articles"), {
recursive: true
});
for (const article of websiteData.articles ?? []) {
for (const article of websiteData.article ?? []) {
const articleFileName = article.title.toLowerCase().split(" ").join("-");
let head = "";
let body = "";
const { head, body } = render(websiteData.content_type === "Blog" ? BlogArticle : DocsArticle, {
props: {
websiteOverview: websiteData,
article,
apiUrl: API_BASE_PREFIX
}
});
switch (websiteData.content_type) {
case "Blog":
{
({ head, body } = render(BlogArticle, {
props: {
favicon: websiteData.favicon_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
: "",
title: article.title,
logoType: websiteData.logo_type,
logo:
websiteData.logo_type === "text"
? (websiteData.logo_text ?? "")
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
coverImage: article.cover_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${article.cover_image}`
: "",
publicationDate: article.publication_date,
mainContent: md(article.main_content ?? ""),
footerAdditionalText: md(websiteData.additional_text ?? ""),
metaDescription: article.meta_description
}
}));
}
break;
case "Docs":
{
({ head, body } = render(DocsArticle, {
props: {
favicon: websiteData.favicon_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
: "",
title: article.title,
logoType: websiteData.logo_type,
logo:
websiteData.logo_type === "text"
? (websiteData.logo_text ?? "")
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
mainContent: md(article.main_content ?? ""),
categorizedArticles: websiteData.categorized_articles ?? [],
footerAdditionalText: md(websiteData.additional_text ?? ""),
metaDescription: article.meta_description
}
}));
}
break;
}
const articleFileContents = `
<!DOCTYPE html>
<html lang="en">
<head>
${head}
</head>
<body>
${body}
</body>
</html>`;
await writeFile(join(uploadDir, "articles", `${articleFileName}.html`), articleFileContents);
await writeFile(
join(uploadDir, "articles", `${articleFileName}.html`),
fileContents(head, body)
);
}
if (websiteData.legal_information_main_content) {
let head = "";
let body = "";
if (websiteData.legal_information) {
const { head, body } = render(websiteData.content_type === "Blog" ? BlogIndex : DocsIndex, {
props: {
websiteOverview: websiteData,
apiUrl: API_BASE_PREFIX,
isLegalPage: true
}
});
switch (websiteData.content_type) {
case "Blog":
{
({ head, body } = render(BlogIndex, {
props: {
favicon: websiteData.favicon_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
: "",
title: "Legal information",
logoType: websiteData.logo_type,
logo:
websiteData.logo_type === "text"
? (websiteData.logo_text ?? "")
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
mainContent: md(websiteData.legal_information_main_content ?? "", false),
articles: [],
footerAdditionalText: md(websiteData.additional_text ?? "")
}
}));
}
break;
case "Docs":
{
({ head, body } = render(DocsIndex, {
props: {
favicon: websiteData.favicon_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
: "",
title: "Legal information",
logoType: websiteData.logo_type,
logo:
websiteData.logo_type === "text"
? (websiteData.logo_text ?? "")
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
mainContent: md(websiteData.legal_information_main_content ?? "", false),
categorizedArticles: {},
footerAdditionalText: md(websiteData.additional_text ?? "")
}
}));
}
break;
}
const legalInformationFileContents = `
<!DOCTYPE html>
<html lang="en">
<head>
${head}
</head>
<body>
${body}
</body>
</html>`;
await writeFile(join(uploadDir, "legal-information.html"), legalInformationFileContents);
await writeFile(join(uploadDir, "legal-information.html"), fileContents(head, body));
}
const commonStyles = await readFile(`${process.cwd()}/template-styles/common-styles.css`, {
@@ -328,14 +165,14 @@ const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean
.concat(specificStyles)
.replace(
/--color-accent:\s*(.*?);/,
`--color-accent: ${websiteData.accent_color_dark_theme};`
`--color-accent: ${websiteData.settings.accent_color_dark_theme};`
)
.replace(
/@media\s*\(prefers-color-scheme:\s*dark\)\s*{[^}]*--color-accent:\s*(.*?);/,
(match) =>
match.replace(
/--color-accent:\s*(.*?);/,
`--color-accent: ${websiteData.accent_color_light_theme};`
`--color-accent: ${websiteData.settings.accent_color_light_theme};`
)
)
);

View File

@@ -17,9 +17,9 @@
{/if}
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
id={data.websiteOverview.id}
contentType={data.websiteOverview.content_type}
title={data.websiteOverview.title}
previewContent={data.websitePreviewUrl}
fullPreview={true}
>
@@ -46,7 +46,7 @@
<button type="submit">Publish</button>
</form>
{#if data.website.is_published}
{#if data.websiteOverview.is_published}
<section id="publication-status">
<h3>
<a href="#publication-status">Publication status</a>

View File

@@ -25,11 +25,12 @@
--color-text: black;
--color-text-invert: white;
--color-border: hsl(0 0% 50%);
--color-accent: hsl(210, 100%, 30%);
--color-success: hsl(105, 100%, 30%);
--color-error: hsl(0, 100%, 30%);
--border-primary: 0.0625rem solid var(--bg-tertiary);
--border-primary: 0.0625rem solid var(--color-border);
--border-radius: 0.125rem;
/* Step -1: 14.9953px → 14.2222px */
@@ -222,6 +223,10 @@ a {
color: var(--color-accent);
}
a:has(img) {
display: inline-block;
}
:is(h1, h2, h3, h4, h5, h6) > a {
color: var(--color-text);
text-decoration: none;
@@ -242,6 +247,7 @@ svg,
video {
max-inline-size: 100%;
block-size: auto;
vertical-align: middle;
}
p,