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,7 +101,25 @@ 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
|
||||||
|
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)
|
INSERT INTO internal.website (content_type, title)
|
||||||
VALUES (create_website.content_type, create_website.title)
|
VALUES (create_website.content_type, create_website.title)
|
||||||
RETURNING
|
RETURNING
|
||||||
@@ -108,7 +140,7 @@ $$
|
|||||||
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