mirror of
https://github.com/thiloho/archtika.git
synced 2025-11-22 10:51:36 +01:00
Add administrator role plus manage dashboard and cleanup database migrations
This commit is contained in:
@@ -67,8 +67,12 @@
|
|||||||
type = "app";
|
type = "app";
|
||||||
program = "${pkgs.writeShellScriptBin "api-setup" ''
|
program = "${pkgs.writeShellScriptBin "api-setup" ''
|
||||||
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64)
|
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.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
|
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:15432/archtika?sslmode=disable --migrations-dir ${self.outPath}/rest-api/db/migrations up
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ CREATE ROLE anon NOLOGIN NOINHERIT;
|
|||||||
|
|
||||||
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
|
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
|
||||||
|
|
||||||
|
CREATE ROLE administrator NOLOGIN;
|
||||||
|
|
||||||
GRANT anon TO authenticator;
|
GRANT anon TO authenticator;
|
||||||
|
|
||||||
GRANT authenticated_user 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 anon;
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA api TO authenticated_user;
|
GRANT USAGE ON SCHEMA api TO authenticated_user;
|
||||||
@@ -25,7 +31,8 @@ CREATE TABLE internal.user (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3),
|
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3),
|
||||||
password_hash CHAR(60) NOT NULL,
|
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()
|
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,
|
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
||||||
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
|
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
|
||||||
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
|
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
|
||||||
|
max_storage_size INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_storage_size') ::INT,
|
||||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
@@ -84,7 +92,7 @@ CREATE TABLE internal.docs_category (
|
|||||||
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
|
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
|
||||||
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
||||||
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
|
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
|
||||||
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||||
@@ -103,7 +111,7 @@ CREATE TABLE internal.article (
|
|||||||
publication_date DATE,
|
publication_date DATE,
|
||||||
main_content VARCHAR(200000) CHECK (TRIM(main_content) != ''),
|
main_content VARCHAR(200000) CHECK (TRIM(main_content) != ''),
|
||||||
category UUID REFERENCES internal.docs_category (id) ON DELETE SET NULL,
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||||
@@ -128,7 +136,7 @@ CREATE TABLE internal.legal_information (
|
|||||||
CREATE TABLE internal.collab (
|
CREATE TABLE internal.collab (
|
||||||
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
|
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
|
||||||
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
|
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
|
||||||
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(),
|
added_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||||
@@ -166,6 +174,8 @@ DROP ROLE anon;
|
|||||||
|
|
||||||
DROP ROLE authenticated_user;
|
DROP ROLE authenticated_user;
|
||||||
|
|
||||||
|
DROP ROLE administrator;
|
||||||
|
|
||||||
DROP ROLE authenticator;
|
DROP ROLE authenticator;
|
||||||
|
|
||||||
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
|
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ CREATE EVENT TRIGGER pgrst_watch ON ddl_command_end
|
|||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP EVENT TRIGGER pgrst_watch;
|
DROP EVENT TRIGGER pgrst_watch;
|
||||||
|
|
||||||
DROP FUNCTION internal.pgrst_watch ();
|
DROP FUNCTION internal.pgrst_watch;
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ BEGIN
|
|||||||
FROM
|
FROM
|
||||||
pg_roles AS r
|
pg_roles AS r
|
||||||
WHERE
|
WHERE
|
||||||
r.rolname = NEW.role)) THEN
|
r.rolname = NEW.user_role)) THEN
|
||||||
RAISE foreign_key_violation
|
RAISE foreign_key_violation
|
||||||
USING message = 'Unknown database role: ' || NEW.role;
|
USING message = 'Unknown database role: ' || NEW.user_role;
|
||||||
END IF;
|
END IF;
|
||||||
RETURN NULL;
|
RETURN NULL;
|
||||||
END
|
END
|
||||||
@@ -48,7 +48,7 @@ CREATE FUNCTION internal.user_role (username TEXT, pass TEXT, OUT role_name NAME
|
|||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT
|
SELECT
|
||||||
u.role INTO role_name
|
u.user_role INTO role_name
|
||||||
FROM
|
FROM
|
||||||
internal.user AS u
|
internal.user AS u
|
||||||
WHERE
|
WHERE
|
||||||
@@ -96,8 +96,17 @@ BEGIN
|
|||||||
RAISE invalid_parameter_value
|
RAISE invalid_parameter_value
|
||||||
USING message = 'Password must contain at least one special character';
|
USING message = 'Password must contain at least one special character';
|
||||||
ELSE
|
ELSE
|
||||||
INSERT INTO internal.user (username, password_hash)
|
INSERT INTO internal.user (username, password_hash, user_role)
|
||||||
VALUES (register.username, register.pass)
|
SELECT
|
||||||
|
register.username,
|
||||||
|
register.pass,
|
||||||
|
CASE WHEN COUNT(*) = 0 THEN
|
||||||
|
'administrator'
|
||||||
|
ELSE
|
||||||
|
'authenticated_user'
|
||||||
|
END
|
||||||
|
FROM
|
||||||
|
internal.user
|
||||||
RETURNING
|
RETURNING
|
||||||
id INTO user_id;
|
id INTO user_id;
|
||||||
END IF;
|
END IF;
|
||||||
@@ -111,7 +120,7 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
_role NAME;
|
_role NAME;
|
||||||
_user_id UUID;
|
_user_id UUID;
|
||||||
_exp INTEGER := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INTEGER + 86400;
|
_exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 86400;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT
|
SELECT
|
||||||
internal.user_role (login.username, login.pass) INTO _role;
|
internal.user_role (login.username, login.pass) INTO _role;
|
||||||
@@ -154,28 +163,28 @@ $$
|
|||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
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
|
-- migrate:down
|
||||||
DROP TRIGGER encrypt_pass ON internal.user;
|
DROP TRIGGER encrypt_pass ON internal.user;
|
||||||
|
|
||||||
DROP TRIGGER ensure_user_role_exists 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;
|
DROP EXTENSION pgjwt;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ CREATE VIEW api.user WITH ( security_invoker = ON
|
|||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
username
|
username,
|
||||||
|
created_at,
|
||||||
|
max_number_websites
|
||||||
FROM
|
FROM
|
||||||
internal.user;
|
internal.user;
|
||||||
|
|
||||||
@@ -24,7 +26,19 @@ CREATE VIEW api.website WITH ( security_invoker = ON
|
|||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
FROM
|
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
|
CREATE VIEW api.settings WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
@@ -87,28 +101,46 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
_website_id UUID;
|
_website_id UUID;
|
||||||
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
|
_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
|
BEGIN
|
||||||
INSERT INTO internal.website (content_type, title)
|
IF (_user_website_count + 1 > _user_max_websites_allowed_count) THEN
|
||||||
VALUES (create_website.content_type, create_website.title)
|
RAISE invalid_parameter_value
|
||||||
RETURNING
|
USING message = FORMAT('Limit of %s websites exceeded', _user_max_websites_allowed_count);
|
||||||
id INTO _website_id;
|
END IF;
|
||||||
INSERT INTO internal.settings (website_id)
|
INSERT INTO internal.website (content_type, title)
|
||||||
VALUES (_website_id);
|
VALUES (create_website.content_type, create_website.title)
|
||||||
INSERT INTO internal.header (website_id, logo_text)
|
RETURNING
|
||||||
VALUES (_website_id, 'archtika ' || create_website.content_type);
|
id INTO _website_id;
|
||||||
INSERT INTO internal.home (website_id, main_content)
|
INSERT INTO internal.settings (website_id)
|
||||||
VALUES (_website_id, '## About
|
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.');
|
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)
|
INSERT INTO internal.footer (website_id, additional_text)
|
||||||
VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS');
|
VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS');
|
||||||
website_id := _website_id;
|
website_id := _website_id;
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
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
|
-- Security invoker only works on views if the user has access to the underlying table
|
||||||
GRANT SELECT ON internal.user TO authenticated_user;
|
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;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP FUNCTION api.create_website (VARCHAR(10), VARCHAR(50));
|
DROP FUNCTION api.create_website;
|
||||||
|
|
||||||
DROP VIEW api.collab;
|
DROP VIEW api.collab;
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
|
|||||||
|
|
||||||
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission 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 $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
@@ -63,19 +63,29 @@ $$
|
|||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
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
|
CREATE POLICY view_user ON internal.user
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (TRUE);
|
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
|
CREATE POLICY view_websites ON internal.website
|
||||||
FOR SELECT
|
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
|
CREATE POLICY update_website ON internal.website
|
||||||
FOR UPDATE
|
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
|
CREATE POLICY delete_website ON internal.website
|
||||||
FOR DELETE
|
FOR DELETE
|
||||||
@@ -180,6 +190,10 @@ CREATE POLICY delete_collaborations ON internal.collab
|
|||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP POLICY view_user ON internal.user;
|
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 view_websites ON internal.website;
|
||||||
|
|
||||||
DROP POLICY delete_website 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 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;
|
ALTER TABLE internal.user DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|||||||
@@ -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 TRIGGER update_collab_last_modified ON internal.collab;
|
||||||
|
|
||||||
DROP FUNCTION internal.update_last_modified ();
|
DROP FUNCTION internal.update_last_modified;
|
||||||
|
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ CREATE CONSTRAINT TRIGGER check_user_not_website_owner
|
|||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP TRIGGER check_user_not_website_owner ON internal.collab;
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ DECLARE
|
|||||||
_mimetype TEXT := _headers ->> 'x-mimetype';
|
_mimetype TEXT := _headers ->> 'x-mimetype';
|
||||||
_original_filename TEXT := _headers ->> 'x-original-filename';
|
_original_filename TEXT := _headers ->> 'x-original-filename';
|
||||||
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif', 'image/svg+xml'];
|
_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;
|
_has_access BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
_has_access = internal.user_has_website_access (_website_id, 20);
|
_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';
|
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp, avif, gif, svg';
|
||||||
ELSIF OCTET_LENGTH($1) > _max_file_size THEN
|
ELSIF OCTET_LENGTH($1) > _max_file_size THEN
|
||||||
RAISE program_limit_exceeded
|
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
|
ELSE
|
||||||
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
||||||
VALUES (_website_id, $1, _mimetype, _original_filename)
|
VALUES (_website_id, $1, _mimetype, _original_filename)
|
||||||
@@ -70,16 +70,16 @@ $$
|
|||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
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
|
-- 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 "*/*";
|
DROP DOMAIN "*/*";
|
||||||
|
|
||||||
|
|||||||
@@ -81,71 +81,71 @@ $$
|
|||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
SECURITY DEFINER;
|
||||||
|
|
||||||
CREATE TRIGGER website_track_changes
|
CREATE TRIGGER track_changes_website
|
||||||
AFTER UPDATE ON internal.website
|
AFTER UPDATE ON internal.website
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
CREATE TRIGGER settings_track_changes
|
CREATE TRIGGER track_changes_settings
|
||||||
AFTER UPDATE ON internal.settings
|
AFTER UPDATE ON internal.settings
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
CREATE TRIGGER header_track_changes
|
CREATE TRIGGER track_changes_header
|
||||||
AFTER UPDATE ON internal.header
|
AFTER UPDATE ON internal.header
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
CREATE TRIGGER home_track_changes
|
CREATE TRIGGER track_changes_home
|
||||||
AFTER UPDATE ON internal.home
|
AFTER UPDATE ON internal.home
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
CREATE TRIGGER article_track_changes
|
CREATE TRIGGER track_changes_article
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON internal.article
|
AFTER INSERT OR UPDATE OR DELETE ON internal.article
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
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
|
AFTER INSERT OR UPDATE OR DELETE ON internal.docs_category
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
CREATE TRIGGER footer_track_changes
|
CREATE TRIGGER track_changes_footer
|
||||||
AFTER UPDATE ON internal.footer
|
AFTER UPDATE ON internal.footer
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
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
|
AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
CREATE TRIGGER collab_track_changes
|
CREATE TRIGGER track_changes_collab
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON internal.collab
|
AFTER INSERT OR UPDATE OR DELETE ON internal.collab
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
-- migrate:down
|
-- 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;
|
DROP VIEW api.change_log;
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ CREATE TRIGGER update_domain_prefix_last_modified
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.update_last_modified ();
|
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
|
AFTER INSERT OR UPDATE OR DELETE ON internal.domain_prefix
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.track_changes ();
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
-- migrate:down
|
-- 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;
|
DROP TRIGGER update_domain_prefix_last_modified ON internal.domain_prefix;
|
||||||
|
|
||||||
|
|||||||
203
rest-api/db/migrations/20241006165029_administrator.sql
Normal file
203
rest-api/db/migrations/20241006165029_administrator.sql
Normal file
@@ -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;
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
||||||
|
import type { User } from "$lib/db-schema";
|
||||||
|
|
||||||
export const handle = async ({ event, resolve }) => {
|
export const handle = async ({ event, resolve }) => {
|
||||||
if (!event.url.pathname.startsWith("/api/")) {
|
if (!event.url.pathname.startsWith("/api/")) {
|
||||||
@@ -20,6 +21,13 @@ export const handle = async ({ event, resolve }) => {
|
|||||||
throw redirect(303, "/");
|
throw redirect(303, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(userData.data as User).user_role !== "administrator" &&
|
||||||
|
event.url.pathname.includes("/manage")
|
||||||
|
) {
|
||||||
|
throw redirect(303, "/");
|
||||||
|
}
|
||||||
|
|
||||||
event.locals.user = userData.data;
|
event.locals.user = userData.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,16 @@
|
|||||||
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
|
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
|
||||||
|
|
||||||
const target = event.target as HTMLTextAreaElement;
|
const target = event.target as HTMLTextAreaElement;
|
||||||
const newContent =
|
const markdownToInsert = ``;
|
||||||
target.value.slice(0, target.selectionStart) +
|
const cursorPosition = target.selectionStart;
|
||||||
`` +
|
const newContent = target.value.slice(0, target.selectionStart) + markdownToInsert;
|
||||||
target.value.slice(target.selectionStart);
|
target.value.slice(target.selectionStart);
|
||||||
|
|
||||||
previewContent.value = newContent;
|
previewContent.value = newContent;
|
||||||
|
|
||||||
|
const newCursorPosition = cursorPosition + markdownToInsert.length;
|
||||||
|
target.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||||
|
target.focus();
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,7 @@
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
border: var(--border-primary);
|
border: var(--border-primary);
|
||||||
inline-size: var(--modal-width);
|
inline-size: min(var(--modal-width), 100%);
|
||||||
max-inline-size: 100%;
|
|
||||||
max-block-size: calc(100vh - var(--space-m));
|
max-block-size: calc(100vh - var(--space-m));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|||||||
@@ -438,19 +438,21 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
role: string;
|
user_role: string;
|
||||||
|
max_number_websites: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
export interface UserInput {
|
export interface UserInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
username: string;
|
username: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
role?: string;
|
user_role?: string;
|
||||||
|
max_number_websites?: number;
|
||||||
created_at?: Date;
|
created_at?: Date;
|
||||||
}
|
}
|
||||||
const user = {
|
const user = {
|
||||||
tableName: "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"],
|
requiredForInsert: ["username", "password_hash"],
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
foreignKeys: {},
|
foreignKeys: {},
|
||||||
@@ -464,6 +466,7 @@ export interface Website {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
max_storage_size: number;
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
last_modified_at: Date;
|
last_modified_at: Date;
|
||||||
@@ -474,6 +477,7 @@ export interface WebsiteInput {
|
|||||||
user_id?: string;
|
user_id?: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
max_storage_size?: number;
|
||||||
is_published?: boolean;
|
is_published?: boolean;
|
||||||
created_at?: Date;
|
created_at?: Date;
|
||||||
last_modified_at?: Date;
|
last_modified_at?: Date;
|
||||||
@@ -486,6 +490,7 @@ const website = {
|
|||||||
"user_id",
|
"user_id",
|
||||||
"content_type",
|
"content_type",
|
||||||
"title",
|
"title",
|
||||||
|
"max_storage_size",
|
||||||
"is_published",
|
"is_published",
|
||||||
"created_at",
|
"created_at",
|
||||||
"last_modified_at",
|
"last_modified_at",
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ export const md = (markdownContent: string, showToc = true) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false }));
|
html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false }));
|
||||||
} catch (_) {
|
} catch (error) {
|
||||||
html = "Failed to parse markdown";
|
html = JSON.stringify(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
|
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 {
|
return {
|
||||||
user: locals.user
|
user: locals.user,
|
||||||
|
storageSizes
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,30 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if data.storageSizes.data.length > 0}
|
||||||
|
<section id="storage">
|
||||||
|
<h2>
|
||||||
|
<a href="#storage">Storage</a>
|
||||||
|
</h2>
|
||||||
|
<ul class="unpadded storage-grid">
|
||||||
|
{#each data.storageSizes.data as { website_title, storage_size_bytes, max_storage_bytes, max_storage_pretty, diff_storage_pretty }}
|
||||||
|
<li>
|
||||||
|
<strong>{website_title}</strong>
|
||||||
|
<label>
|
||||||
|
{max_storage_pretty} total — {diff_storage_pretty} free<br />
|
||||||
|
<meter
|
||||||
|
value={storage_size_bytes}
|
||||||
|
min="0"
|
||||||
|
max={max_storage_bytes}
|
||||||
|
high={max_storage_bytes * 0.75}
|
||||||
|
></meter>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section id="logout">
|
<section id="logout">
|
||||||
<h2>
|
<h2>
|
||||||
<a href="#logout">Logout</a>
|
<a href="#logout">Logout</a>
|
||||||
@@ -71,4 +95,22 @@
|
|||||||
form[action="?/logout"] > button {
|
form[action="?/logout"] > button {
|
||||||
max-inline-size: fit-content;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
68
web-app/src/routes/(authenticated)/manage/+page.server.ts
Normal file
68
web-app/src/routes/(authenticated)/manage/+page.server.ts
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
126
web-app/src/routes/(authenticated)/manage/+page.svelte
Normal file
126
web-app/src/routes/(authenticated)/manage/+page.svelte
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import Modal from "$lib/components/Modal.svelte";
|
||||||
|
import SuccessOrError from "$lib/components/SuccessOrError.svelte";
|
||||||
|
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
|
||||||
|
import type { ActionData, PageServerData } from "./$types";
|
||||||
|
import { enhanceForm } from "$lib/utils";
|
||||||
|
import { sending } from "$lib/runes.svelte";
|
||||||
|
import DateTime from "$lib/components/DateTime.svelte";
|
||||||
|
|
||||||
|
const { data, form }: { data: PageServerData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SuccessOrError success={form?.success} message={form?.message} />
|
||||||
|
|
||||||
|
{#if sending.value}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section id="all-users">
|
||||||
|
<h2>
|
||||||
|
<a href="#all-users">All users</a>
|
||||||
|
</h2>
|
||||||
|
<div class="scroll-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account creation</th>
|
||||||
|
<th>UUID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Manage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.allUsers as { user_id, user_created_at, username, max_number_websites, websites }}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<DateTime date={user_created_at} />
|
||||||
|
</td>
|
||||||
|
<td>{user_id}</td>
|
||||||
|
<td>{username}</td>
|
||||||
|
<td>
|
||||||
|
<Modal id="manage-user-{user_id}" text="Manage">
|
||||||
|
<hgroup>
|
||||||
|
<h3>Manage user</h3>
|
||||||
|
<p>User "{username}"</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateMaxWebsiteAmount"
|
||||||
|
use:enhance={enhanceForm({ reset: false })}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="user-id" value={user_id} />
|
||||||
|
<label>
|
||||||
|
Number of websites allowed:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="number-of-websites"
|
||||||
|
min="0"
|
||||||
|
value={max_number_websites}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if websites.length > 0}
|
||||||
|
<h4>Websites</h4>
|
||||||
|
{#each websites as { id, title, max_storage_size }}
|
||||||
|
<details>
|
||||||
|
<summary>{title}</summary>
|
||||||
|
<div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateStorageLimit"
|
||||||
|
use:enhance={enhanceForm({ reset: false })}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="website-id" value={id} />
|
||||||
|
<label>
|
||||||
|
Storage limit in MB:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="storage-size"
|
||||||
|
min="0"
|
||||||
|
value={max_storage_size}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h4>Delete user</h4>
|
||||||
|
<details>
|
||||||
|
<summary>Delete</summary>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<strong>Caution!</strong>
|
||||||
|
Deleting the user will irretrievably erase all their data.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteUser"
|
||||||
|
use:enhance={enhanceForm({ closeModal: true })}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="user-id" value={user_id} />
|
||||||
|
<button type="submit">Delete user</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</Modal>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form[action="?/deleteUser"] {
|
||||||
|
margin-block-start: var(--space-2xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -46,8 +46,7 @@
|
|||||||
<a href="#publication-status">Publication status</a>
|
<a href="#publication-status">Publication status</a>
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Your website is published at:
|
Your website is published at:<br />
|
||||||
<br />
|
|
||||||
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
|
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -53,6 +53,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<ul class="link-wrapper unpadded">
|
<ul class="link-wrapper unpadded">
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
|
{#if data.user.user_role === "administrator"}
|
||||||
|
<li>
|
||||||
|
<a href="/manage">Manage</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
<li>
|
<li>
|
||||||
<a href="/account">Account</a>
|
<a href="/account">Account</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user