From 1b74e1e6fb6040a6a823a2218c1ef4c099cff85e Mon Sep 17 00:00:00 2001
From: thiloho <123883702+thiloho@users.noreply.github.com>
Date: Tue, 8 Oct 2024 21:20:44 +0200
Subject: [PATCH] Add administrator role plus manage dashboard and cleanup
database migrations
---
flake.nix | 4 +
.../migrations/20240719071602_main_tables.sql | 18 +-
...73454_automatic_schema_cache_reloading.sql | 2 +-
...240720074103_user_management_roles_jwt.sql | 39 ++--
...20240720132802_exposed_views_functions.sql | 66 ++++--
.../20240724191017_row_level_security.sql | 24 ++-
.../20240805132306_last_modified_triggers.sql | 2 +-
.../20240808141708_collaborator_not_owner.sql | 2 +-
.../20240810115846_image_upload_function.sql | 14 +-
.../migrations/20240911070907_change_log.sql | 38 ++--
.../20240920090915_custom_domain_prefix.sql | 4 +-
.../20241006165029_administrator.sql | 203 ++++++++++++++++++
web-app/src/hooks.server.ts | 8 +
.../src/lib/components/MarkdownEditor.svelte | 12 +-
web-app/src/lib/components/Modal.svelte | 3 +-
web-app/src/lib/db-schema.ts | 11 +-
web-app/src/lib/utils.ts | 4 +-
.../(authenticated)/account/+page.server.ts | 14 +-
.../(authenticated)/account/+page.svelte | 42 ++++
.../(authenticated)/manage/+page.server.ts | 68 ++++++
.../(authenticated)/manage/+page.svelte | 126 +++++++++++
.../website/[websiteId]/publish/+page.svelte | 3 +-
web-app/src/routes/+layout.svelte | 5 +
23 files changed, 625 insertions(+), 87 deletions(-)
create mode 100644 rest-api/db/migrations/20241006165029_administrator.sql
create mode 100644 web-app/src/routes/(authenticated)/manage/+page.server.ts
create mode 100644 web-app/src/routes/(authenticated)/manage/+page.svelte
diff --git a/flake.nix b/flake.nix
index 364073a..16c72e7 100644
--- a/flake.nix
+++ b/flake.nix
@@ -67,8 +67,12 @@
type = "app";
program = "${pkgs.writeShellScriptBin "api-setup" ''
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64)
+ WEBSITE_MAX_STORAGE_SIZE=100
+ WEBSITE_MAX_NUMBER_USER=3
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.jwt_secret\" TO '$JWT_SECRET'"
+ ${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.website_max_storage_size\" TO $WEBSITE_MAX_STORAGE_SIZE"
+ ${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.website_max_number_user\" TO $WEBSITE_MAX_NUMBER_USER"
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:15432/archtika?sslmode=disable --migrations-dir ${self.outPath}/rest-api/db/migrations up
diff --git a/rest-api/db/migrations/20240719071602_main_tables.sql b/rest-api/db/migrations/20240719071602_main_tables.sql
index 18b312e..a374607 100644
--- a/rest-api/db/migrations/20240719071602_main_tables.sql
+++ b/rest-api/db/migrations/20240719071602_main_tables.sql
@@ -9,10 +9,16 @@ CREATE ROLE anon NOLOGIN NOINHERIT;
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
+CREATE ROLE administrator NOLOGIN;
+
GRANT anon TO authenticator;
GRANT authenticated_user TO authenticator;
+GRANT administrator TO authenticator;
+
+GRANT authenticated_user TO administrator;
+
GRANT USAGE ON SCHEMA api TO anon;
GRANT USAGE ON SCHEMA api TO authenticated_user;
@@ -25,7 +31,8 @@ CREATE TABLE internal.user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3),
password_hash CHAR(60) NOT NULL,
- role NAME NOT NULL DEFAULT 'authenticated_user',
+ user_role NAME NOT NULL DEFAULT 'authenticated_user',
+ max_number_websites INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_number_user') ::INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP()
);
@@ -34,6 +41,7 @@ CREATE TABLE internal.website (
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
+ max_storage_size INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_storage_size') ::INT,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
@@ -84,7 +92,7 @@ CREATE TABLE internal.docs_category (
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,
+ category_weight INT CHECK (category_weight >= 0) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
@@ -103,7 +111,7 @@ CREATE TABLE internal.article (
publication_date DATE,
main_content VARCHAR(200000) 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),
+ article_weight INT 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,
@@ -128,7 +136,7 @@ CREATE TABLE internal.legal_information (
CREATE TABLE internal.collab (
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
- permission_level INTEGER CHECK (permission_level IN (10, 20, 30)) NOT NULL DEFAULT 10,
+ permission_level INT CHECK (permission_level IN (10, 20, 30)) NOT NULL DEFAULT 10,
added_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,
@@ -166,6 +174,8 @@ DROP ROLE anon;
DROP ROLE authenticated_user;
+DROP ROLE administrator;
+
DROP ROLE authenticator;
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
diff --git a/rest-api/db/migrations/20240720073454_automatic_schema_cache_reloading.sql b/rest-api/db/migrations/20240720073454_automatic_schema_cache_reloading.sql
index b7b7859..1c9c1c9 100644
--- a/rest-api/db/migrations/20240720073454_automatic_schema_cache_reloading.sql
+++ b/rest-api/db/migrations/20240720073454_automatic_schema_cache_reloading.sql
@@ -15,5 +15,5 @@ CREATE EVENT TRIGGER pgrst_watch ON ddl_command_end
-- migrate:down
DROP EVENT TRIGGER pgrst_watch;
-DROP FUNCTION internal.pgrst_watch ();
+DROP FUNCTION internal.pgrst_watch;
diff --git a/rest-api/db/migrations/20240720074103_user_management_roles_jwt.sql b/rest-api/db/migrations/20240720074103_user_management_roles_jwt.sql
index 3504dee..28af12b 100644
--- a/rest-api/db/migrations/20240720074103_user_management_roles_jwt.sql
+++ b/rest-api/db/migrations/20240720074103_user_management_roles_jwt.sql
@@ -13,9 +13,9 @@ BEGIN
FROM
pg_roles AS r
WHERE
- r.rolname = NEW.role)) THEN
+ r.rolname = NEW.user_role)) THEN
RAISE foreign_key_violation
- USING message = 'Unknown database role: ' || NEW.role;
+ USING message = 'Unknown database role: ' || NEW.user_role;
END IF;
RETURN NULL;
END
@@ -48,7 +48,7 @@ CREATE FUNCTION internal.user_role (username TEXT, pass TEXT, OUT role_name NAME
AS $$
BEGIN
SELECT
- u.role INTO role_name
+ u.user_role INTO role_name
FROM
internal.user AS u
WHERE
@@ -96,8 +96,17 @@ BEGIN
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)
+ INSERT INTO internal.user (username, password_hash, user_role)
+ SELECT
+ register.username,
+ register.pass,
+ CASE WHEN COUNT(*) = 0 THEN
+ 'administrator'
+ ELSE
+ 'authenticated_user'
+ END
+ FROM
+ internal.user
RETURNING
id INTO user_id;
END IF;
@@ -111,7 +120,7 @@ AS $$
DECLARE
_role NAME;
_user_id UUID;
- _exp INTEGER := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INTEGER + 86400;
+ _exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 86400;
BEGIN
SELECT
internal.user_role (login.username, login.pass) INTO _role;
@@ -154,28 +163,28 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
-GRANT EXECUTE ON FUNCTION api.register (TEXT, TEXT) TO anon;
+GRANT EXECUTE ON FUNCTION api.register TO anon;
-GRANT EXECUTE ON FUNCTION api.login (TEXT, TEXT) TO anon;
+GRANT EXECUTE ON FUNCTION api.login TO anon;
-GRANT EXECUTE ON FUNCTION api.delete_account (TEXT) TO authenticated_user;
+GRANT EXECUTE ON FUNCTION api.delete_account 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.register;
-DROP FUNCTION api.login (TEXT, TEXT);
+DROP FUNCTION api.login;
-DROP FUNCTION api.delete_account (TEXT);
+DROP FUNCTION api.delete_account;
-DROP FUNCTION internal.user_role (TEXT, TEXT);
+DROP FUNCTION internal.user_role;
-DROP FUNCTION internal.encrypt_pass ();
+DROP FUNCTION internal.encrypt_pass;
-DROP FUNCTION internal.check_role_exists ();
+DROP FUNCTION internal.check_role_exists;
DROP EXTENSION pgjwt;
diff --git a/rest-api/db/migrations/20240720132802_exposed_views_functions.sql b/rest-api/db/migrations/20240720132802_exposed_views_functions.sql
index 48aaf39..2835147 100644
--- a/rest-api/db/migrations/20240720132802_exposed_views_functions.sql
+++ b/rest-api/db/migrations/20240720132802_exposed_views_functions.sql
@@ -15,7 +15,9 @@ CREATE VIEW api.user WITH ( security_invoker = ON
) AS
SELECT
id,
- username
+ username,
+ created_at,
+ max_number_websites
FROM
internal.user;
@@ -24,7 +26,19 @@ CREATE VIEW api.website WITH ( security_invoker = ON
SELECT
*
FROM
- internal.website;
+ internal.website AS w
+WHERE
+ w.user_id = (
+ CURRENT_SETTING(
+ 'request.jwt.claims', TRUE
+)::JSON ->> 'user_id')::UUID
+ OR w.id IN (
+ SELECT
+ c.website_id
+ FROM
+ internal.collab AS c
+ WHERE
+ c.user_id = (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID);
CREATE VIEW api.settings WITH ( security_invoker = ON
) AS
@@ -87,28 +101,46 @@ AS $$
DECLARE
_website_id UUID;
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
+ _user_website_count INT := (
+ SELECT
+ COUNT(*)
+ FROM
+ internal.website AS w
+ WHERE
+ w.user_id = _user_id);
+ _user_max_websites_allowed_count INT := (
+ SELECT
+ u.max_number_websites
+ FROM
+ internal.user AS u
+ WHERE
+ id = _user_id);
BEGIN
- INSERT INTO internal.website (content_type, title)
- VALUES (create_website.content_type, create_website.title)
- RETURNING
- id INTO _website_id;
- INSERT INTO internal.settings (website_id)
- VALUES (_website_id);
- 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
+ IF (_user_website_count + 1 > _user_max_websites_allowed_count) THEN
+ RAISE invalid_parameter_value
+ USING message = FORMAT('Limit of %s websites exceeded', _user_max_websites_allowed_count);
+ END IF;
+ INSERT INTO internal.website (content_type, title)
+ VALUES (create_website.content_type, create_website.title)
+ RETURNING
+ id INTO _website_id;
+ INSERT INTO internal.settings (website_id)
+ VALUES (_website_id);
+ 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
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates. It is also possible to add contributors to your sites, which is very useful for larger projects where, for example, several people are constantly working on the documentation.');
- 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;
+ 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;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
-GRANT EXECUTE ON FUNCTION api.create_website (VARCHAR(10), VARCHAR(50)) TO authenticated_user;
+GRANT EXECUTE ON FUNCTION api.create_website TO authenticated_user;
-- Security invoker only works on views if the user has access to the underlying table
GRANT SELECT ON internal.user TO authenticated_user;
@@ -154,7 +186,7 @@ GRANT SELECT, INSERT (website_id, user_id, permission_level), UPDATE (permission
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
-- migrate:down
-DROP FUNCTION api.create_website (VARCHAR(10), VARCHAR(50));
+DROP FUNCTION api.create_website;
DROP VIEW api.collab;
diff --git a/rest-api/db/migrations/20240724191017_row_level_security.sql b/rest-api/db/migrations/20240724191017_row_level_security.sql
index 5acc1a9..479973c 100644
--- a/rest-api/db/migrations/20240724191017_row_level_security.sql
+++ b/rest-api/db/migrations/20240724191017_row_level_security.sql
@@ -21,7 +21,7 @@ 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, OUT has_access BOOLEAN)
+CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INT, collaborator_permission_level INT DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE, OUT has_access BOOLEAN)
AS $$
DECLARE
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
@@ -63,19 +63,29 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
-GRANT EXECUTE ON FUNCTION internal.user_has_website_access (UUID, INTEGER, INTEGER, UUID, UUID, BOOLEAN) TO authenticated_user;
+GRANT EXECUTE ON FUNCTION internal.user_has_website_access TO authenticated_user;
CREATE POLICY view_user ON internal.user
FOR SELECT
USING (TRUE);
+CREATE POLICY update_user ON internal.user
+ FOR UPDATE
+ USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator');
+
+CREATE POLICY delete_user ON internal.user
+ FOR DELETE
+ USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator');
+
CREATE POLICY view_websites ON internal.website
FOR SELECT
- USING (internal.user_has_website_access (id, 10, raise_error => FALSE));
+ USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator'
+ OR internal.user_has_website_access (id, 10, raise_error => FALSE));
CREATE POLICY update_website ON internal.website
FOR UPDATE
- USING (internal.user_has_website_access (id, 30));
+ USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator'
+ OR internal.user_has_website_access (id, 30));
CREATE POLICY delete_website ON internal.website
FOR DELETE
@@ -180,6 +190,10 @@ CREATE POLICY delete_collaborations ON internal.collab
-- migrate:down
DROP POLICY view_user ON internal.user;
+DROP POLICY update_user ON internal.user;
+
+DROP POLICY delete_user ON internal.user;
+
DROP POLICY view_websites ON internal.website;
DROP POLICY delete_website ON internal.website;
@@ -234,7 +248,7 @@ DROP POLICY update_collaborations ON internal.collab;
DROP POLICY delete_collaborations ON internal.collab;
-DROP FUNCTION internal.user_has_website_access (UUID, INTEGER, INTEGER, UUID, UUID, BOOLEAN);
+DROP FUNCTION internal.user_has_website_access;
ALTER TABLE internal.user DISABLE ROW LEVEL SECURITY;
diff --git a/rest-api/db/migrations/20240805132306_last_modified_triggers.sql b/rest-api/db/migrations/20240805132306_last_modified_triggers.sql
index 8dedd0b..71075cb 100644
--- a/rest-api/db/migrations/20240805132306_last_modified_triggers.sql
+++ b/rest-api/db/migrations/20240805132306_last_modified_triggers.sql
@@ -97,5 +97,5 @@ DROP TRIGGER update_legal_information_last_modified ON internal.legal_informatio
DROP TRIGGER update_collab_last_modified ON internal.collab;
-DROP FUNCTION internal.update_last_modified ();
+DROP FUNCTION internal.update_last_modified;
diff --git a/rest-api/db/migrations/20240808141708_collaborator_not_owner.sql b/rest-api/db/migrations/20240808141708_collaborator_not_owner.sql
index bf46316..6a0474b 100644
--- a/rest-api/db/migrations/20240808141708_collaborator_not_owner.sql
+++ b/rest-api/db/migrations/20240808141708_collaborator_not_owner.sql
@@ -26,5 +26,5 @@ CREATE CONSTRAINT TRIGGER check_user_not_website_owner
-- migrate:down
DROP TRIGGER check_user_not_website_owner ON internal.collab;
-DROP FUNCTION internal.check_user_not_website_owner ();
+DROP FUNCTION internal.check_user_not_website_owner;
diff --git a/rest-api/db/migrations/20240810115846_image_upload_function.sql b/rest-api/db/migrations/20240810115846_image_upload_function.sql
index d7214da..4c34969 100644
--- a/rest-api/db/migrations/20240810115846_image_upload_function.sql
+++ b/rest-api/db/migrations/20240810115846_image_upload_function.sql
@@ -9,7 +9,7 @@ DECLARE
_mimetype TEXT := _headers ->> 'x-mimetype';
_original_filename TEXT := _headers ->> 'x-original-filename';
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif', 'image/svg+xml'];
- _max_file_size INT := 5 * 1024 * 1024;
+ _max_file_size BIGINT := 5 * 1024 * 1024;
_has_access BOOLEAN;
BEGIN
_has_access = internal.user_has_website_access (_website_id, 20);
@@ -24,7 +24,7 @@ BEGIN
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp, avif, gif, svg';
ELSIF 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));
+ USING message = FORMAT('File size exceeds the maximum limit of %s', PG_SIZE_PRETTY(_max_file_size));
ELSE
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
VALUES (_website_id, $1, _mimetype, _original_filename)
@@ -70,16 +70,16 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
-GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
+GRANT EXECUTE ON FUNCTION api.upload_file TO authenticated_user;
-GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO anon;
+GRANT EXECUTE ON FUNCTION api.retrieve_file TO anon;
-GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO authenticated_user;
+GRANT EXECUTE ON FUNCTION api.retrieve_file TO authenticated_user;
-- migrate:down
-DROP FUNCTION api.upload_file (BYTEA);
+DROP FUNCTION api.upload_file;
-DROP FUNCTION api.retrieve_file (UUID);
+DROP FUNCTION api.retrieve_file;
DROP DOMAIN "*/*";
diff --git a/rest-api/db/migrations/20240911070907_change_log.sql b/rest-api/db/migrations/20240911070907_change_log.sql
index cc61118..aa3c23b 100644
--- a/rest-api/db/migrations/20240911070907_change_log.sql
+++ b/rest-api/db/migrations/20240911070907_change_log.sql
@@ -81,71 +81,71 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
-CREATE TRIGGER website_track_changes
+CREATE TRIGGER track_changes_website
AFTER UPDATE ON internal.website
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-CREATE TRIGGER settings_track_changes
+CREATE TRIGGER track_changes_settings
AFTER UPDATE ON internal.settings
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-CREATE TRIGGER header_track_changes
+CREATE TRIGGER track_changes_header
AFTER UPDATE ON internal.header
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-CREATE TRIGGER home_track_changes
+CREATE TRIGGER track_changes_home
AFTER UPDATE ON internal.home
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-CREATE TRIGGER article_track_changes
+CREATE TRIGGER track_changes_article
AFTER INSERT OR UPDATE OR DELETE ON internal.article
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-CREATE TRIGGER docs_category_track_changes
+CREATE TRIGGER track_changes_docs_category
AFTER INSERT OR UPDATE OR DELETE ON internal.docs_category
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-CREATE TRIGGER footer_track_changes
+CREATE TRIGGER track_changes_footer
AFTER UPDATE ON internal.footer
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-CREATE TRIGGER legal_information_track_changes
+CREATE TRIGGER track_changes_legal_information
AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-CREATE TRIGGER collab_track_changes
+CREATE TRIGGER track_changes_collab
AFTER INSERT OR UPDATE OR DELETE ON internal.collab
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-- migrate:down
-DROP TRIGGER website_track_changes ON internal.website;
+DROP TRIGGER track_changes_website ON internal.website;
-DROP TRIGGER settings_track_changes ON internal.settings;
+DROP TRIGGER track_changes_settings ON internal.settings;
-DROP TRIGGER header_track_changes ON internal.header;
+DROP TRIGGER track_changes_header ON internal.header;
-DROP TRIGGER home_track_changes ON internal.home;
+DROP TRIGGER track_changes_home ON internal.home;
-DROP TRIGGER article_track_changes ON internal.article;
+DROP TRIGGER track_changes_article ON internal.article;
-DROP TRIGGER docs_category_track_changes ON internal.docs_category;
+DROP TRIGGER track_changes_docs_category ON internal.docs_category;
-DROP TRIGGER footer_track_changes ON internal.footer;
+DROP TRIGGER track_changes_footer ON internal.footer;
-DROP TRIGGER legal_information_track_changes ON internal.legal_information;
+DROP TRIGGER track_changes_legal_information ON internal.legal_information;
-DROP TRIGGER collab_track_changes ON internal.collab;
+DROP TRIGGER track_changes_collab ON internal.collab;
-DROP FUNCTION internal.track_changes ();
+DROP FUNCTION internal.track_changes;
DROP VIEW api.change_log;
diff --git a/rest-api/db/migrations/20240920090915_custom_domain_prefix.sql b/rest-api/db/migrations/20240920090915_custom_domain_prefix.sql
index ba1dece..cfdd78f 100644
--- a/rest-api/db/migrations/20240920090915_custom_domain_prefix.sql
+++ b/rest-api/db/migrations/20240920090915_custom_domain_prefix.sql
@@ -41,13 +41,13 @@ CREATE TRIGGER update_domain_prefix_last_modified
FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified ();
-CREATE TRIGGER domain_prefix_track_changes
+CREATE TRIGGER track_changes_domain_prefix
AFTER INSERT OR UPDATE OR DELETE ON internal.domain_prefix
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-- migrate:down
-DROP TRIGGER domain_prefix_track_changes ON internal.domain_prefix;
+DROP TRIGGER track_changes_domain_prefix ON internal.domain_prefix;
DROP TRIGGER update_domain_prefix_last_modified ON internal.domain_prefix;
diff --git a/rest-api/db/migrations/20241006165029_administrator.sql b/rest-api/db/migrations/20241006165029_administrator.sql
new file mode 100644
index 0000000..4eb0a9d
--- /dev/null
+++ b/rest-api/db/migrations/20241006165029_administrator.sql
@@ -0,0 +1,203 @@
+-- migrate:up
+CREATE FUNCTION api.user_websites_storage_size ()
+ RETURNS TABLE (
+ website_id UUID,
+ website_title VARCHAR(50),
+ storage_size_bytes BIGINT,
+ storage_size_pretty TEXT,
+ max_storage_bytes BIGINT,
+ max_storage_pretty TEXT,
+ diff_storage_pretty TEXT
+ )
+ AS $$
+DECLARE
+ _user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
+ _tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'domain_prefix', 'footer', 'header', 'home', 'legal_information', 'media', 'settings', 'change_log'];
+ _query TEXT;
+ _union_queries TEXT := '';
+BEGIN
+ FOR i IN 1..ARRAY_LENGTH(_tables, 1)
+ LOOP
+ _union_queries := _union_queries || FORMAT('
+ SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%s AS t WHERE t.website_id = w.id', _tables[i]);
+ IF i < ARRAY_LENGTH(_tables, 1) THEN
+ _union_queries := _union_queries || ' UNION ALL ';
+ END IF;
+ END LOOP;
+ _query := FORMAT('
+ SELECT
+ w.id AS website_id,
+ w.title AS website_title,
+ COALESCE(SUM(sizes.total_size), 0)::BIGINT AS storage_size_bytes,
+ PG_SIZE_PRETTY(COALESCE(SUM(sizes.total_size), 0)) AS storage_size_pretty,
+ (w.max_storage_size::BIGINT * 1024 * 1024) AS max_storage_bytes,
+ PG_SIZE_PRETTY(w.max_storage_size::BIGINT * 1024 * 1024) AS max_storage_pretty,
+ PG_SIZE_PRETTY((w.max_storage_size::BIGINT * 1024 * 1024) - COALESCE(SUM(sizes.total_size), 0)) AS diff_storage_pretty
+ FROM
+ internal.website AS w
+ LEFT JOIN LATERAL (
+ %s
+ ) AS sizes(total_size) ON TRUE
+ WHERE
+ w.user_id = $1
+ GROUP BY
+ w.id,
+ w.title
+ ORDER BY
+ storage_size_bytes DESC', _union_queries);
+ RETURN QUERY EXECUTE _query
+ USING _user_id;
+END;
+$$
+LANGUAGE plpgsql
+SECURITY DEFINER;
+
+GRANT EXECUTE ON FUNCTION api.user_websites_storage_size TO authenticated_user;
+
+CREATE FUNCTION internal.prevent_website_storage_size_excess ()
+ RETURNS TRIGGER
+ AS $$
+DECLARE
+ _website_id UUID := NEW.website_id;
+ _current_size BIGINT;
+ _size_difference BIGINT := PG_COLUMN_SIZE(NEW) - COALESCE(PG_COLUMN_SIZE(OLD), 0);
+ _max_storage_mb INT := (
+ SELECT
+ w.max_storage_size
+ FROM
+ internal.website AS w
+ WHERE
+ w.id = _website_id);
+ _max_storage_bytes BIGINT := _max_storage_mb::BIGINT * 1024 * 1024;
+ _tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'domain_prefix', 'footer', 'header', 'home', 'legal_information', 'media', 'settings', 'change_log'];
+ _union_queries TEXT := '';
+ _query TEXT;
+BEGIN
+ FOR i IN 1..ARRAY_LENGTH(_tables, 1)
+ LOOP
+ _union_queries := _union_queries || FORMAT('
+ SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%s AS t WHERE t.website_id = $1', _tables[i]);
+ IF i < ARRAY_LENGTH(_tables, 1) THEN
+ _union_queries := _union_queries || ' UNION ALL ';
+ END IF;
+ END LOOP;
+ _query := FORMAT('
+ SELECT COALESCE(SUM(sizes.total_size), 0)::BIGINT
+ FROM (%s) AS sizes(total_size)', _union_queries);
+ EXECUTE _query INTO _current_size
+ USING _website_id;
+ IF (_current_size + _size_difference) > _max_storage_bytes THEN
+ RAISE program_limit_exceeded
+ USING message = FORMAT('Storage limit exceeded. Current size: %s, Max size: %s', PG_SIZE_PRETTY(_current_size), PG_SIZE_PRETTY(_max_storage_bytes));
+ END IF;
+ RETURN NEW;
+END;
+$$
+LANGUAGE plpgsql
+SECURITY DEFINER;
+
+CREATE TRIGGER _prevent_storage_excess_article
+ BEFORE INSERT OR UPDATE ON internal.article
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_collab
+ BEFORE INSERT OR UPDATE ON internal.collab
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_docs_category
+ BEFORE INSERT OR UPDATE ON internal.docs_category
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_domain_prefix
+ BEFORE INSERT OR UPDATE ON internal.domain_prefix
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_footer
+ BEFORE UPDATE ON internal.footer
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_header
+ BEFORE UPDATE ON internal.header
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_home
+ BEFORE UPDATE ON internal.home
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_legal_information
+ BEFORE INSERT OR UPDATE ON internal.legal_information
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_media
+ BEFORE INSERT ON internal.media
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE TRIGGER _prevent_storage_excess_settings
+ BEFORE UPDATE ON internal.settings
+ FOR EACH ROW
+ EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
+
+CREATE VIEW api.all_user_websites AS
+SELECT
+ u.id AS user_id,
+ u.username,
+ u.created_at AS user_created_at,
+ u.max_number_websites,
+ COALESCE(JSONB_AGG(JSONB_BUILD_OBJECT('id', w.id, 'title', w.title, 'max_storage_size', w.max_storage_size)
+ ORDER BY w.created_at DESC) FILTER (WHERE w.id IS NOT NULL), '[]'::JSONB) AS websites
+FROM
+ internal.user AS u
+ LEFT JOIN internal.website AS w ON u.id = w.user_id
+GROUP BY
+ u.id;
+
+GRANT SELECT ON api.all_user_websites TO administrator;
+
+GRANT UPDATE (max_storage_size) ON internal.website TO administrator;
+
+GRANT UPDATE, DELETE ON internal.user TO administrator;
+
+GRANT UPDATE, DELETE ON api.user TO administrator;
+
+-- migrate:down
+DROP FUNCTION api.user_websites_storage_size;
+
+DROP TRIGGER _prevent_storage_excess_article ON internal.article;
+
+DROP TRIGGER _prevent_storage_excess_collab ON internal.collab;
+
+DROP TRIGGER _prevent_storage_excess_docs_category ON internal.docs_category;
+
+DROP TRIGGER _prevent_storage_excess_domain_prefix ON internal.domain_prefix;
+
+DROP TRIGGER _prevent_storage_excess_footer ON internal.footer;
+
+DROP TRIGGER _prevent_storage_excess_header ON internal.header;
+
+DROP TRIGGER _prevent_storage_excess_home ON internal.home;
+
+DROP TRIGGER _prevent_storage_excess_legal_information ON internal.legal_information;
+
+DROP TRIGGER _prevent_storage_excess_media ON internal.media;
+
+DROP TRIGGER _prevent_storage_excess_settings ON internal.settings;
+
+DROP FUNCTION internal.prevent_website_storage_size_excess;
+
+DROP VIEW api.all_user_websites;
+
+REVOKE UPDATE (max_storage_size) ON internal.website FROM administrator;
+
+REVOKE UPDATE, DELETE ON internal.user FROM administrator;
+
+REVOKE UPDATE, DELETE ON api.user FROM administrator;
+
diff --git a/web-app/src/hooks.server.ts b/web-app/src/hooks.server.ts
index 73cf58b..b09d74a 100644
--- a/web-app/src/hooks.server.ts
+++ b/web-app/src/hooks.server.ts
@@ -1,5 +1,6 @@
import { redirect } from "@sveltejs/kit";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
+import type { User } from "$lib/db-schema";
export const handle = async ({ event, resolve }) => {
if (!event.url.pathname.startsWith("/api/")) {
@@ -20,6 +21,13 @@ export const handle = async ({ event, resolve }) => {
throw redirect(303, "/");
}
+ if (
+ (userData.data as User).user_role !== "administrator" &&
+ event.url.pathname.includes("/manage")
+ ) {
+ throw redirect(303, "/");
+ }
+
event.locals.user = userData.data;
}
}
diff --git a/web-app/src/lib/components/MarkdownEditor.svelte b/web-app/src/lib/components/MarkdownEditor.svelte
index 2f0dbd7..3cc278a 100644
--- a/web-app/src/lib/components/MarkdownEditor.svelte
+++ b/web-app/src/lib/components/MarkdownEditor.svelte
@@ -46,12 +46,16 @@
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
const target = event.target as HTMLTextAreaElement;
- const newContent =
- target.value.slice(0, target.selectionStart) +
- `` +
- target.value.slice(target.selectionStart);
+ const markdownToInsert = ``;
+ const cursorPosition = target.selectionStart;
+ const newContent = target.value.slice(0, target.selectionStart) + markdownToInsert;
+ target.value.slice(target.selectionStart);
previewContent.value = newContent;
+
+ const newCursorPosition = cursorPosition + markdownToInsert.length;
+ target.setSelectionRange(newCursorPosition, newCursorPosition);
+ target.focus();
} else {
return;
}
diff --git a/web-app/src/lib/components/Modal.svelte b/web-app/src/lib/components/Modal.svelte
index 2a01eef..967ad75 100644
--- a/web-app/src/lib/components/Modal.svelte
+++ b/web-app/src/lib/components/Modal.svelte
@@ -51,8 +51,7 @@
background-color: var(--bg-primary);
border-radius: var(--border-radius);
border: var(--border-primary);
- inline-size: var(--modal-width);
- max-inline-size: 100%;
+ inline-size: min(var(--modal-width), 100%);
max-block-size: calc(100vh - var(--space-m));
overflow-y: auto;
z-index: 20;
diff --git a/web-app/src/lib/db-schema.ts b/web-app/src/lib/db-schema.ts
index 4b67699..08845aa 100644
--- a/web-app/src/lib/db-schema.ts
+++ b/web-app/src/lib/db-schema.ts
@@ -438,19 +438,21 @@ export interface User {
id: string;
username: string;
password_hash: string;
- role: string;
+ user_role: string;
+ max_number_websites: number;
created_at: Date;
}
export interface UserInput {
id?: string;
username: string;
password_hash: string;
- role?: string;
+ user_role?: string;
+ max_number_websites?: number;
created_at?: Date;
}
const user = {
tableName: "user",
- columns: ["id", "username", "password_hash", "role", "created_at"],
+ columns: ["id", "username", "password_hash", "user_role", "max_number_websites", "created_at"],
requiredForInsert: ["username", "password_hash"],
primaryKey: "id",
foreignKeys: {},
@@ -464,6 +466,7 @@ export interface Website {
user_id: string;
content_type: string;
title: string;
+ max_storage_size: number;
is_published: boolean;
created_at: Date;
last_modified_at: Date;
@@ -474,6 +477,7 @@ export interface WebsiteInput {
user_id?: string;
content_type: string;
title: string;
+ max_storage_size?: number;
is_published?: boolean;
created_at?: Date;
last_modified_at?: Date;
@@ -486,6 +490,7 @@ const website = {
"user_id",
"content_type",
"title",
+ "max_storage_size",
"is_published",
"created_at",
"last_modified_at",
diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts
index 17bfc53..edb07b3 100644
--- a/web-app/src/lib/utils.ts
+++ b/web-app/src/lib/utils.ts
@@ -151,8 +151,8 @@ export const md = (markdownContent: string, showToc = true) => {
try {
html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false }));
- } catch (_) {
- html = "Failed to parse markdown";
+ } catch (error) {
+ html = JSON.stringify(error);
}
return html;
diff --git a/web-app/src/routes/(authenticated)/account/+page.server.ts b/web-app/src/routes/(authenticated)/account/+page.server.ts
index aa61f82..e189032 100644
--- a/web-app/src/routes/(authenticated)/account/+page.server.ts
+++ b/web-app/src/routes/(authenticated)/account/+page.server.ts
@@ -1,9 +1,19 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
-export const load: PageServerLoad = async ({ locals }) => {
+export const load: PageServerLoad = async ({ fetch, locals }) => {
+ const storageSizes = await apiRequest(
+ fetch,
+ `${API_BASE_PREFIX}/rpc/user_websites_storage_size`,
+ "GET",
+ {
+ returnData: true
+ }
+ );
+
return {
- user: locals.user
+ user: locals.user,
+ storageSizes
};
};
diff --git a/web-app/src/routes/(authenticated)/account/+page.svelte b/web-app/src/routes/(authenticated)/account/+page.svelte
index d089728..4ceff51 100644
--- a/web-app/src/routes/(authenticated)/account/+page.svelte
+++ b/web-app/src/routes/(authenticated)/account/+page.svelte
@@ -33,6 +33,30 @@
+{#if data.storageSizes.data.length > 0}
+ User "{username}"
+ Caution!
+ Deleting the user will irretrievably erase all their data.
+
- Your website is published at:
-
+ Storage
+
+
+ {#each data.storageSizes.data as { website_title, storage_size_bytes, max_storage_bytes, max_storage_pretty, diff_storage_pretty }}
+
+
Logout
@@ -71,4 +95,22 @@
form[action="?/logout"] > button {
max-inline-size: fit-content;
}
+
+ .storage-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 35ch), 1fr));
+ row-gap: var(--space-s);
+ column-gap: var(--space-m);
+ }
+
+ .storage-grid > li {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3xs);
+ }
+
+ meter {
+ inline-size: min(512px, 100%);
+ block-size: 2rem;
+ }
diff --git a/web-app/src/routes/(authenticated)/manage/+page.server.ts b/web-app/src/routes/(authenticated)/manage/+page.server.ts
new file mode 100644
index 0000000..3215a2e
--- /dev/null
+++ b/web-app/src/routes/(authenticated)/manage/+page.server.ts
@@ -0,0 +1,68 @@
+import type { Actions, PageServerLoad } from "./$types";
+import { API_BASE_PREFIX } from "$lib/server/utils";
+import { apiRequest } from "$lib/server/utils";
+
+export const load: PageServerLoad = async ({ fetch }) => {
+ const allUsers = (
+ await apiRequest(
+ fetch,
+ `${API_BASE_PREFIX}/all_user_websites?order=user_created_at.desc`,
+ "GET",
+ {
+ returnData: true
+ }
+ )
+ ).data;
+
+ return {
+ allUsers,
+ API_BASE_PREFIX
+ };
+};
+
+export const actions: Actions = {
+ updateMaxWebsiteAmount: async ({ request, fetch }) => {
+ const data = await request.formData();
+
+ return await apiRequest(
+ fetch,
+ `${API_BASE_PREFIX}/user?id=eq.${data.get("user-id")}`,
+ "PATCH",
+ {
+ body: {
+ max_number_websites: data.get("number-of-websites")
+ },
+ successMessage: "Successfully updated user website limit"
+ }
+ );
+ },
+ updateStorageLimit: async ({ request, fetch }) => {
+ const data = await request.formData();
+
+ console.log(`${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`);
+
+ return await apiRequest(
+ fetch,
+ `${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`,
+ "PATCH",
+ {
+ body: {
+ max_storage_size: data.get("storage-size")
+ },
+ successMessage: "Successfully updated user website storage size"
+ }
+ );
+ },
+ deleteUser: async ({ request, fetch }) => {
+ const data = await request.formData();
+
+ return await apiRequest(
+ fetch,
+ `${API_BASE_PREFIX}/user?id=eq.${data.get("user-id")}`,
+ "DELETE",
+ {
+ successMessage: "Successfully deleted user"
+ }
+ );
+ }
+};
diff --git a/web-app/src/routes/(authenticated)/manage/+page.svelte b/web-app/src/routes/(authenticated)/manage/+page.svelte
new file mode 100644
index 0000000..fb36e3d
--- /dev/null
+++ b/web-app/src/routes/(authenticated)/manage/+page.svelte
@@ -0,0 +1,126 @@
+
+
+
+ All users
+
+
+
+
+
+
+
+
+ {#each data.allUsers as { user_id, user_created_at, username, max_number_websites, websites }}
+ Account creation
+ UUID
+ Username
+ Manage
+
+
+ {/each}
+
+
+
+ {user_id}
+ {username}
+
+
+ Manage user
+ Websites
+ {#each websites as { id, title, max_storage_size }}
+ {title}
+ Delete user
+ Delete
+
+ Your website is published at:
{data.websiteProdUrl}