mirror of
https://github.com/thiloho/archtika.git
synced 2025-11-22 02:41:35 +01:00
4
.github/workflows/demo-server-deploy.yml
vendored
4
.github/workflows/demo-server-deploy.yml
vendored
@@ -2,8 +2,8 @@ name: Deploy app to demo server (demo.archtika.com)
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ['Playwright tests']
|
workflows: [ 'Playwright tests' ]
|
||||||
types: [completed]
|
types: [ completed ]
|
||||||
env:
|
env:
|
||||||
SERVER_USER: root
|
SERVER_USER: root
|
||||||
SERVER_IP: 128.140.75.240
|
SERVER_IP: 128.140.75.240
|
||||||
|
|||||||
5
.github/workflows/playwright.yml
vendored
5
.github/workflows/playwright.yml
vendored
@@ -2,8 +2,9 @@ name: Playwright tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [ main ]
|
||||||
- main
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
shellHook = ''
|
shellHook = ''
|
||||||
alias dbmate="${pkgs.dbmate}/bin/dbmate --no-dump-schema --url postgres://postgres@localhost:15432/archtika?sslmode=disable"
|
alias dbmate="${pkgs.dbmate}/bin/dbmate --no-dump-schema --url postgres://postgres@localhost:15432/archtika?sslmode=disable"
|
||||||
alias formatsql="${pkgs.pgformatter}/bin/pg_format -s 2 -f 2 -U 2 -i db/migrations/*.sql"
|
alias formatsql="${pkgs.pgformatter}/bin/pg_format -s 2 -f 2 -U 2 -i db/migrations/*.sql"
|
||||||
|
alias dbconnect="${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
web = pkgs.mkShell {
|
web = pkgs.mkShell {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
nixpkgs.config.allowUnfree = true;
|
nixpkgs.config.allowUnfree = true;
|
||||||
|
|
||||||
networking = {
|
networking = {
|
||||||
hostName = "archtika-demo-server";
|
hostName = "archtika-qs";
|
||||||
networkmanager.enable = true;
|
networkmanager.enable = true;
|
||||||
firewall = {
|
firewall = {
|
||||||
allowedTCPPorts = [
|
allowedTCPPorts = [
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
services.archtika = {
|
services.archtika = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = localArchtikaPackage;
|
package = localArchtikaPackage;
|
||||||
jwtSecret = "a42kVyAhTImYxZeebZkApoAZLmf0VtDA";
|
jwtSecret = /var/lib/archtika-jwt-secret.txt;
|
||||||
domain = "demo.archtika.com";
|
domain = "qs.archtika.com";
|
||||||
acmeEmail = "thilo.hohlt@tutanota.com";
|
acmeEmail = "thilo.hohlt@tutanota.com";
|
||||||
dnsProvider = "porkbun";
|
dnsProvider = "porkbun";
|
||||||
dnsEnvironmentFile = /var/lib/porkbun.env;
|
dnsEnvironmentFile = /var/lib/porkbun.env;
|
||||||
|
|||||||
@@ -1,41 +1,32 @@
|
|||||||
# Do not modify this file! It was generated by ‘nixos-generate-config’
|
# Do not modify this file! It was generated by ‘nixos-generate-config’
|
||||||
# and may be overwritten by future invocations. Please make changes
|
# and may be overwritten by future invocations. Please make changes
|
||||||
# to /etc/nixos/configuration.nix instead.
|
# to /etc/nixos/configuration.nix instead.
|
||||||
{
|
{ config, lib, pkgs, modulesPath, ... }:
|
||||||
config,
|
|
||||||
lib,
|
|
||||||
pkgs,
|
|
||||||
modulesPath,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
|
|
||||||
{
|
{
|
||||||
imports = [ (modulesPath + "/profiles/qemu-guest.nix") ];
|
imports =
|
||||||
|
[ (modulesPath + "/profiles/qemu-guest.nix")
|
||||||
|
];
|
||||||
|
|
||||||
boot.initrd.availableKernelModules = [
|
boot.initrd.availableKernelModules = [ "xhci_pci" "virtio_scsi" "sr_mod" ];
|
||||||
"xhci_pci"
|
|
||||||
"virtio_scsi"
|
|
||||||
"sr_mod"
|
|
||||||
];
|
|
||||||
boot.initrd.kernelModules = [ ];
|
boot.initrd.kernelModules = [ ];
|
||||||
boot.kernelModules = [ ];
|
boot.kernelModules = [ ];
|
||||||
boot.extraModulePackages = [ ];
|
boot.extraModulePackages = [ ];
|
||||||
|
|
||||||
fileSystems."/" = {
|
fileSystems."/" =
|
||||||
device = "/dev/disk/by-uuid/af7c3a41-3427-4354-8bf5-bd98698792c5";
|
{ device = "/dev/disk/by-uuid/04fa460b-c39f-47f8-bece-c044d767209c";
|
||||||
fsType = "ext4";
|
fsType = "ext4";
|
||||||
};
|
};
|
||||||
|
|
||||||
fileSystems."/boot" = {
|
fileSystems."/boot" =
|
||||||
device = "/dev/disk/by-uuid/BA3C-CCAF";
|
{ device = "/dev/disk/by-uuid/BA11-3E3D";
|
||||||
fsType = "vfat";
|
fsType = "vfat";
|
||||||
options = [
|
options = [ "fmask=0077" "dmask=0077" ];
|
||||||
"fmask=0077"
|
};
|
||||||
"dmask=0077"
|
|
||||||
|
swapDevices =
|
||||||
|
[ { device = "/dev/disk/by-uuid/abace260-6904-4b38-8532-0235f77cb2bf"; }
|
||||||
];
|
];
|
||||||
};
|
|
||||||
|
|
||||||
swapDevices = [ { device = "/dev/disk/by-uuid/a58e9054-0b76-4da5-ae13-0d73515ec41e"; } ];
|
|
||||||
|
|
||||||
# Enables DHCP on each ethernet and wireless interface. In case of scripted networking
|
# Enables DHCP on each ethernet and wireless interface. In case of scripted networking
|
||||||
# (the default) this is the recommended approach. When using systemd-networkd it's
|
# (the default) this is the recommended approach. When using systemd-networkd it's
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
];
|
];
|
||||||
locations = {
|
locations = {
|
||||||
"/" = {
|
"/" = {
|
||||||
root = "/var/www/archtika-websites";
|
root = "/var/www/archtika-websites/";
|
||||||
index = "index.html";
|
index = "index.html";
|
||||||
tryFiles = "$uri $uri/ $uri.html $uri/index.html index.html =404";
|
tryFiles = "$uri $uri/ $uri.html $uri/index.html index.html =404";
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
-- migrate:up
|
-- migrate:up
|
||||||
|
CREATE SCHEMA internal;
|
||||||
|
|
||||||
CREATE SCHEMA api;
|
CREATE SCHEMA api;
|
||||||
|
|
||||||
|
CREATE ROLE authenticator LOGIN NOINHERIT NOCREATEDB NOCREATEROLE NOSUPERUSER;
|
||||||
|
|
||||||
CREATE ROLE anon NOLOGIN NOINHERIT;
|
CREATE ROLE anon NOLOGIN NOINHERIT;
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA api TO anon;
|
|
||||||
|
|
||||||
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
|
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA api TO authenticated_user;
|
|
||||||
|
|
||||||
CREATE ROLE authenticator LOGIN NOINHERIT NOCREATEDB NOCREATEROLE NOSUPERUSER;
|
|
||||||
|
|
||||||
GRANT anon TO authenticator;
|
GRANT anon TO authenticator;
|
||||||
|
|
||||||
GRANT authenticated_user TO authenticator;
|
GRANT authenticated_user TO authenticator;
|
||||||
|
|
||||||
CREATE SCHEMA internal;
|
GRANT USAGE ON SCHEMA api TO anon;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA api TO authenticated_user;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA internal TO authenticated_user;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
|
||||||
|
|
||||||
CREATE TABLE internal.user (
|
CREATE TABLE internal.user (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
@@ -30,14 +34,16 @@ CREATE TABLE internal.website (
|
|||||||
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) != ''),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||||
|
title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE internal.media (
|
CREATE TABLE internal.media (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
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 CASCADE NOT 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,
|
||||||
blob BYTEA NOT NULL,
|
blob BYTEA NOT NULL,
|
||||||
mimetype TEXT NOT NULL,
|
mimetype TEXT NOT NULL,
|
||||||
original_name TEXT NOT NULL,
|
original_name TEXT NOT NULL,
|
||||||
@@ -70,19 +76,35 @@ CREATE TABLE internal.home (
|
|||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE internal.docs_category (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
|
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
|
||||||
|
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
||||||
|
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
|
||||||
|
category_weight INTEGER CHECK (category_weight >= 0) NOT NULL,
|
||||||
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
|
||||||
|
UNIQUE (website_id, category_name),
|
||||||
|
UNIQUE (website_id, category_weight)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE internal.article (
|
CREATE TABLE internal.article (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
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,
|
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
||||||
title VARCHAR(100) NOT NULL CHECK (TRIM(title) != ''),
|
title VARCHAR(100) NOT NULL CHECK (TRIM(title) != ''),
|
||||||
meta_description VARCHAR(250) CHECK (TRIM(meta_description) != ''),
|
meta_description VARCHAR(250) CHECK (TRIM(meta_description) != ''),
|
||||||
meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''),
|
meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''),
|
||||||
cover_image UUID REFERENCES internal.media (id) ON DELETE SET NULL,
|
cover_image UUID REFERENCES internal.media (id) ON DELETE SET NULL,
|
||||||
publication_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
publication_date DATE,
|
||||||
main_content TEXT CHECK (TRIM(main_content) != ''),
|
main_content TEXT CHECK (TRIM(main_content) != ''),
|
||||||
|
category UUID REFERENCES internal.docs_category (id) ON DELETE SET NULL,
|
||||||
|
article_weight INTEGER CHECK (article_weight IS NULL OR article_weight >= 0),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
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,
|
||||||
|
title_description_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(title, '') || ' ' || COALESCE(meta_description, ''))) STORED,
|
||||||
|
UNIQUE (website_id, category, article_weight)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE internal.footer (
|
CREATE TABLE internal.footer (
|
||||||
@@ -92,6 +114,13 @@ CREATE TABLE internal.footer (
|
|||||||
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE internal.legal_information (
|
||||||
|
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
|
||||||
|
main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''),
|
||||||
|
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
|
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE internal.collab (
|
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,
|
||||||
@@ -102,25 +131,17 @@ CREATE TABLE internal.collab (
|
|||||||
PRIMARY KEY (website_id, user_id)
|
PRIMARY KEY (website_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE internal.change_log (
|
|
||||||
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
|
|
||||||
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
|
||||||
change_summary VARCHAR(255) NOT NULL,
|
|
||||||
previous_value JSONB,
|
|
||||||
new_value JSONB,
|
|
||||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
|
||||||
PRIMARY KEY (website_id, user_id, TIMESTAMP)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP TABLE internal.change_log;
|
|
||||||
|
|
||||||
DROP TABLE internal.collab;
|
DROP TABLE internal.collab;
|
||||||
|
|
||||||
|
DROP TABLE internal.legal_information;
|
||||||
|
|
||||||
DROP TABLE internal.footer;
|
DROP TABLE internal.footer;
|
||||||
|
|
||||||
DROP TABLE internal.article;
|
DROP TABLE internal.article;
|
||||||
|
|
||||||
|
DROP TABLE internal.docs_category;
|
||||||
|
|
||||||
DROP TABLE internal.home;
|
DROP TABLE internal.home;
|
||||||
|
|
||||||
DROP TABLE internal.header;
|
DROP TABLE internal.header;
|
||||||
@@ -131,15 +152,17 @@ DROP TABLE internal.media;
|
|||||||
|
|
||||||
DROP TABLE internal.website;
|
DROP TABLE internal.website;
|
||||||
|
|
||||||
DROP SCHEMA api;
|
|
||||||
|
|
||||||
DROP TABLE internal.user;
|
DROP TABLE internal.user;
|
||||||
|
|
||||||
DROP SCHEMA internal;
|
DROP SCHEMA api;
|
||||||
|
|
||||||
DROP ROLE authenticator;
|
DROP SCHEMA internal;
|
||||||
|
|
||||||
DROP ROLE anon;
|
DROP ROLE anon;
|
||||||
|
|
||||||
DROP ROLE authenticated_user;
|
DROP ROLE authenticated_user;
|
||||||
|
|
||||||
|
DROP ROLE authenticator;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- migrate:up
|
-- migrate:up
|
||||||
CREATE FUNCTION pgrst_watch ()
|
CREATE FUNCTION pgrst_watch ()
|
||||||
RETURNS event_trigger
|
RETURNS EVENT_TRIGGER
|
||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
NOTIFY pgrst,
|
NOTIFY pgrst,
|
||||||
|
|||||||
@@ -7,18 +7,17 @@ CREATE FUNCTION internal.check_role_exists ()
|
|||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF (NOT EXISTS (
|
||||||
SELECT
|
SELECT
|
||||||
1
|
1
|
||||||
FROM
|
FROM
|
||||||
pg_roles AS r
|
pg_roles AS r
|
||||||
WHERE
|
WHERE
|
||||||
r.rolname = NEW.role) THEN
|
r.rolname = NEW.role)) THEN
|
||||||
RAISE foreign_key_violation
|
RAISE foreign_key_violation
|
||||||
USING message = 'Unknown database role: ' || NEW.role;
|
USING message = 'Unknown database role: ' || NEW.role;
|
||||||
RETURN NULL;
|
END IF;
|
||||||
END IF;
|
RETURN NULL;
|
||||||
RETURN NEW;
|
|
||||||
END
|
END
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql;
|
LANGUAGE plpgsql;
|
||||||
@@ -45,23 +44,21 @@ CREATE TRIGGER encrypt_pass
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.encrypt_pass ();
|
EXECUTE FUNCTION internal.encrypt_pass ();
|
||||||
|
|
||||||
CREATE FUNCTION internal.user_role (username TEXT, PASSWORD TEXT)
|
CREATE FUNCTION internal.user_role (username TEXT, pass TEXT, OUT role_name NAME)
|
||||||
RETURNS NAME
|
AS $$
|
||||||
AS $$
|
|
||||||
BEGIN
|
BEGIN
|
||||||
RETURN (
|
SELECT
|
||||||
SELECT
|
ROLE INTO role_name
|
||||||
ROLE
|
FROM
|
||||||
FROM
|
internal.user AS u
|
||||||
internal.user AS u
|
WHERE
|
||||||
WHERE
|
u.username = user_role.username
|
||||||
u.username = user_role.username
|
AND u.password_hash = CRYPT(user_role.pass, u.password_hash);
|
||||||
AND u.password_hash = CRYPT(user_role.password, u.password_hash));
|
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql;
|
LANGUAGE plpgsql;
|
||||||
|
|
||||||
CREATE FUNCTION api.register (username TEXT, PASSWORD TEXT, OUT user_id UUID)
|
CREATE FUNCTION api.register (username TEXT, pass TEXT, OUT user_id UUID)
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
_username_length_min CONSTANT INT := 3;
|
_username_length_min CONSTANT INT := 3;
|
||||||
@@ -69,52 +66,47 @@ DECLARE
|
|||||||
_password_length_min CONSTANT INT := 12;
|
_password_length_min CONSTANT INT := 12;
|
||||||
_password_length_max CONSTANT INT := 128;
|
_password_length_max CONSTANT INT := 128;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF LENGTH(register.username)
|
IF (LENGTH(register.username)
|
||||||
NOT BETWEEN _username_length_min AND _username_length_max THEN
|
NOT BETWEEN _username_length_min AND _username_length_max) THEN
|
||||||
RAISE string_data_length_mismatch
|
RAISE string_data_length_mismatch
|
||||||
USING message = FORMAT('Username must be between %s and %s characters long', _username_length_min, _username_length_max);
|
USING message = FORMAT('Username must be between %s and %s characters long', _username_length_min, _username_length_max);
|
||||||
END IF;
|
ELSIF (EXISTS (
|
||||||
IF EXISTS (
|
SELECT
|
||||||
SELECT
|
1
|
||||||
1
|
FROM
|
||||||
FROM
|
internal.user AS u
|
||||||
internal.user AS u
|
WHERE
|
||||||
WHERE
|
u.username = register.username)) THEN
|
||||||
u.username = register.username) THEN
|
|
||||||
RAISE unique_violation
|
RAISE unique_violation
|
||||||
USING message = 'Username is already taken';
|
USING message = 'Username is already taken';
|
||||||
|
ELSIF (LENGTH(register.pass)
|
||||||
|
NOT BETWEEN _password_length_min AND _password_length_max) THEN
|
||||||
|
RAISE string_data_length_mismatch
|
||||||
|
USING message = FORMAT('Password must be between %s and %s characters long', _password_length_min, _password_length_max);
|
||||||
|
ELSIF register.pass !~ '[a-z]' THEN
|
||||||
|
RAISE invalid_parameter_value
|
||||||
|
USING message = 'Password must contain at least one lowercase letter';
|
||||||
|
ELSIF register.pass !~ '[A-Z]' THEN
|
||||||
|
RAISE invalid_parameter_value
|
||||||
|
USING message = 'Password must contain at least one uppercase letter';
|
||||||
|
ELSIF register.pass !~ '[0-9]' THEN
|
||||||
|
RAISE invalid_parameter_value
|
||||||
|
USING message = 'Password must contain at least one number';
|
||||||
|
ELSIF register.pass !~ '[!@#$%^&*(),.?":{}|<>]' THEN
|
||||||
|
RAISE invalid_parameter_value
|
||||||
|
USING message = 'Password must contain at least one special character';
|
||||||
|
ELSE
|
||||||
|
INSERT INTO internal.user (username, password_hash)
|
||||||
|
VALUES (register.username, register.pass)
|
||||||
|
RETURNING
|
||||||
|
id INTO user_id;
|
||||||
END IF;
|
END IF;
|
||||||
IF LENGTH(register.password)
|
|
||||||
NOT BETWEEN _password_length_min AND _password_length_max THEN
|
|
||||||
RAISE string_data_length_mismatch
|
|
||||||
USING message = FORMAT('Password must be between %s and %s characters long', _password_length_min, _password_length_max);
|
|
||||||
END IF;
|
|
||||||
IF register.password !~ '[a-z]' THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Password must contain at least one lowercase letter';
|
|
||||||
END IF;
|
|
||||||
IF register.password !~ '[A-Z]' THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Password must contain at least one uppercase letter';
|
|
||||||
END IF;
|
|
||||||
IF register.password !~ '[0-9]' THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Password must contain at least one number';
|
|
||||||
END IF;
|
|
||||||
IF register.password !~ '[!@#$%^&*(),.?":{}|<>]' THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Password must contain at least one special character';
|
|
||||||
END IF;
|
|
||||||
INSERT INTO internal.user (username, password_hash)
|
|
||||||
VALUES (register.username, register.password)
|
|
||||||
RETURNING
|
|
||||||
id INTO user_id;
|
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
SECURITY DEFINER;
|
||||||
|
|
||||||
CREATE FUNCTION api.login (username TEXT, PASSWORD TEXT, OUT token TEXT)
|
CREATE FUNCTION api.login (username TEXT, pass TEXT, OUT token TEXT)
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
_role NAME;
|
_role NAME;
|
||||||
@@ -122,11 +114,11 @@ DECLARE
|
|||||||
_exp INTEGER;
|
_exp INTEGER;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT
|
SELECT
|
||||||
internal.user_role (login.username, login.password) INTO _role;
|
internal.user_role (login.username, login.pass) INTO _role;
|
||||||
IF _role IS NULL THEN
|
IF _role IS NULL THEN
|
||||||
RAISE invalid_password
|
RAISE invalid_password
|
||||||
USING message = 'Invalid username or password';
|
USING message = 'Invalid username or password';
|
||||||
END IF;
|
ELSE
|
||||||
SELECT
|
SELECT
|
||||||
id INTO _user_id
|
id INTO _user_id
|
||||||
FROM
|
FROM
|
||||||
@@ -136,26 +128,28 @@ BEGIN
|
|||||||
_exp := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INTEGER + 86400;
|
_exp := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INTEGER + 86400;
|
||||||
SELECT
|
SELECT
|
||||||
SIGN(JSON_BUILD_OBJECT('role', _role, 'user_id', _user_id, 'username', login.username, 'exp', _exp), CURRENT_SETTING('app.jwt_secret')) INTO token;
|
SIGN(JSON_BUILD_OBJECT('role', _role, 'user_id', _user_id, 'username', login.username, 'exp', _exp), CURRENT_SETTING('app.jwt_secret')) INTO token;
|
||||||
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
SECURITY DEFINER;
|
SECURITY DEFINER;
|
||||||
|
|
||||||
CREATE FUNCTION api.delete_account (PASSWORD TEXT, OUT was_deleted BOOLEAN)
|
CREATE FUNCTION api.delete_account (pass TEXT, OUT was_deleted BOOLEAN)
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
_username TEXT := CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'username';
|
_username TEXT := CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'username';
|
||||||
_role NAME;
|
_role NAME;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT
|
SELECT
|
||||||
internal.user_role (_username, delete_account.password) INTO _role;
|
internal.user_role (_username, delete_account.pass) INTO _role;
|
||||||
IF _role IS NULL THEN
|
IF _role IS NULL THEN
|
||||||
RAISE invalid_password
|
RAISE invalid_password
|
||||||
USING message = 'Invalid password';
|
USING message = 'Invalid password';
|
||||||
END IF;
|
ELSE
|
||||||
DELETE FROM internal.user AS u
|
DELETE FROM internal.user AS u
|
||||||
WHERE u.username = _username;
|
WHERE u.username = _username;
|
||||||
was_deleted := TRUE;
|
was_deleted := TRUE;
|
||||||
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
@@ -165,7 +159,13 @@ GRANT EXECUTE ON FUNCTION api.register (TEXT, TEXT) TO anon;
|
|||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.login (TEXT, TEXT) TO anon;
|
GRANT EXECUTE ON FUNCTION api.login (TEXT, TEXT) TO anon;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION api.delete_account (TEXT) TO authenticated_user;
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
|
DROP TRIGGER encrypt_pass ON internal.user;
|
||||||
|
|
||||||
|
DROP TRIGGER ensure_user_role_exists ON internal.user;
|
||||||
|
|
||||||
DROP FUNCTION api.register (TEXT, TEXT);
|
DROP FUNCTION api.register (TEXT, TEXT);
|
||||||
|
|
||||||
DROP FUNCTION api.login (TEXT, TEXT);
|
DROP FUNCTION api.login (TEXT, TEXT);
|
||||||
@@ -174,12 +174,8 @@ DROP FUNCTION api.delete_account (TEXT);
|
|||||||
|
|
||||||
DROP FUNCTION internal.user_role (TEXT, TEXT);
|
DROP FUNCTION internal.user_role (TEXT, TEXT);
|
||||||
|
|
||||||
DROP TRIGGER encrypt_pass ON internal.user;
|
|
||||||
|
|
||||||
DROP FUNCTION internal.encrypt_pass ();
|
DROP FUNCTION internal.encrypt_pass ();
|
||||||
|
|
||||||
DROP TRIGGER ensure_user_role_exists ON internal.user;
|
|
||||||
|
|
||||||
DROP FUNCTION internal.check_role_exists ();
|
DROP FUNCTION internal.check_role_exists ();
|
||||||
|
|
||||||
DROP EXTENSION pgjwt;
|
DROP EXTENSION pgjwt;
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
CREATE VIEW api.account WITH ( security_invoker = ON
|
CREATE VIEW api.account WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
*
|
||||||
username
|
|
||||||
FROM
|
FROM
|
||||||
internal.user
|
internal.user
|
||||||
WHERE
|
WHERE
|
||||||
@@ -23,109 +22,72 @@ FROM
|
|||||||
CREATE VIEW api.website WITH ( security_invoker = ON
|
CREATE VIEW api.website WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
*
|
||||||
user_id,
|
|
||||||
content_type,
|
|
||||||
title,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
FROM
|
||||||
internal.website;
|
internal.website;
|
||||||
|
|
||||||
CREATE VIEW api.settings WITH ( security_invoker = ON
|
CREATE VIEW api.settings WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
website_id,
|
*
|
||||||
accent_color_light_theme,
|
|
||||||
accent_color_dark_theme,
|
|
||||||
favicon_image,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
FROM
|
||||||
internal.settings;
|
internal.settings;
|
||||||
|
|
||||||
CREATE VIEW api.header WITH ( security_invoker = ON
|
CREATE VIEW api.header WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
website_id,
|
*
|
||||||
logo_type,
|
|
||||||
logo_text,
|
|
||||||
logo_image,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
FROM
|
||||||
internal.header;
|
internal.header;
|
||||||
|
|
||||||
CREATE VIEW api.home WITH ( security_invoker = ON
|
CREATE VIEW api.home WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
website_id,
|
*
|
||||||
main_content,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
FROM
|
||||||
internal.home;
|
internal.home;
|
||||||
|
|
||||||
CREATE VIEW api.article WITH ( security_invoker = ON
|
CREATE VIEW api.article WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
*
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
meta_description,
|
|
||||||
meta_author,
|
|
||||||
cover_image,
|
|
||||||
publication_date,
|
|
||||||
main_content,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
FROM
|
||||||
internal.article;
|
internal.article;
|
||||||
|
|
||||||
|
CREATE VIEW api.docs_category WITH ( security_invoker = ON
|
||||||
|
) AS
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM
|
||||||
|
internal.docs_category;
|
||||||
|
|
||||||
CREATE VIEW api.footer WITH ( security_invoker = ON
|
CREATE VIEW api.footer WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
website_id,
|
*
|
||||||
additional_text,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
FROM
|
||||||
internal.footer;
|
internal.footer;
|
||||||
|
|
||||||
|
CREATE VIEW api.legal_information WITH ( security_invoker = ON
|
||||||
|
) AS
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM
|
||||||
|
internal.legal_information;
|
||||||
|
|
||||||
CREATE VIEW api.collab WITH ( security_invoker = ON
|
CREATE VIEW api.collab WITH ( security_invoker = ON
|
||||||
) AS
|
) AS
|
||||||
SELECT
|
SELECT
|
||||||
website_id,
|
*
|
||||||
user_id,
|
|
||||||
permission_level,
|
|
||||||
added_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
FROM
|
||||||
internal.collab;
|
internal.collab;
|
||||||
|
|
||||||
CREATE VIEW api.change_log WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
change_summary,
|
|
||||||
previous_value,
|
|
||||||
new_value,
|
|
||||||
timestamp
|
|
||||||
FROM
|
|
||||||
internal.change_log;
|
|
||||||
|
|
||||||
CREATE FUNCTION api.create_website (content_type VARCHAR(10), title VARCHAR(50), OUT website_id UUID)
|
CREATE FUNCTION api.create_website (content_type VARCHAR(10), title VARCHAR(50), OUT website_id UUID)
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
_website_id UUID;
|
_website_id UUID;
|
||||||
_user_id UUID;
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
BEGIN
|
BEGIN
|
||||||
_user_id := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
|
||||||
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
|
||||||
@@ -135,8 +97,7 @@ BEGIN
|
|||||||
INSERT INTO internal.header (website_id, logo_text)
|
INSERT INTO internal.header (website_id, logo_text)
|
||||||
VALUES (_website_id, 'archtika ' || create_website.content_type);
|
VALUES (_website_id, 'archtika ' || create_website.content_type);
|
||||||
INSERT INTO internal.home (website_id, main_content)
|
INSERT INTO internal.home (website_id, main_content)
|
||||||
VALUES (_website_id, '
|
VALUES (_website_id, '## About
|
||||||
## 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.
|
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates.
|
||||||
|
|
||||||
@@ -148,8 +109,7 @@ For the backend, PostgreSQL is used in combination with PostgREST to create a RE
|
|||||||
|
|
||||||
The web application uses SvelteKit with SSR (Server Side Rendering) and Svelte version 5, currently in beta.
|
The web application uses SvelteKit with SSR (Server Side Rendering) and Svelte version 5, currently in beta.
|
||||||
|
|
||||||
NGINX is used to deploy the websites, serving the static site files from the `/var/www/archtika-websites` directory. The static files can be found in this directory via the path `<user_id>/<website_id>`, which is dynamically created by the web application.
|
NGINX is used to deploy the websites, serving the static site files from the `/var/www/archtika-websites` directory. The static files can be found in this directory via the path `<user_id>/<website_id>`, which is dynamically created by the web application.');
|
||||||
');
|
|
||||||
INSERT INTO internal.footer (website_id, additional_text)
|
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;
|
||||||
@@ -187,29 +147,35 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON internal.article TO authenticated_user;
|
|||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.docs_category TO authenticated_user;
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON api.docs_category TO authenticated_user;
|
||||||
|
|
||||||
GRANT SELECT, UPDATE ON internal.footer TO authenticated_user;
|
GRANT SELECT, UPDATE ON internal.footer TO authenticated_user;
|
||||||
|
|
||||||
GRANT SELECT, UPDATE ON api.footer TO authenticated_user;
|
GRANT SELECT, UPDATE ON api.footer TO authenticated_user;
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.legal_information TO authenticated_user;
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON api.legal_information TO authenticated_user;
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.collab TO authenticated_user;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.collab TO authenticated_user;
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
|
||||||
|
|
||||||
GRANT SELECT ON internal.change_log TO authenticated_user;
|
|
||||||
|
|
||||||
GRANT SELECT ON api.change_log TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
DROP FUNCTION api.create_website (VARCHAR(10), VARCHAR(50));
|
DROP FUNCTION api.create_website (VARCHAR(10), VARCHAR(50));
|
||||||
|
|
||||||
DROP VIEW api.change_log;
|
|
||||||
|
|
||||||
DROP VIEW api.collab;
|
DROP VIEW api.collab;
|
||||||
|
|
||||||
|
DROP VIEW api.legal_information;
|
||||||
|
|
||||||
DROP VIEW api.footer;
|
DROP VIEW api.footer;
|
||||||
|
|
||||||
DROP VIEW api.home;
|
DROP VIEW api.home;
|
||||||
|
|
||||||
|
DROP VIEW api.docs_category;
|
||||||
|
|
||||||
DROP VIEW api.article;
|
DROP VIEW api.article;
|
||||||
|
|
||||||
DROP VIEW api.header;
|
DROP VIEW api.header;
|
||||||
|
|||||||
@@ -13,29 +13,30 @@ ALTER TABLE internal.home ENABLE ROW LEVEL SECURITY;
|
|||||||
|
|
||||||
ALTER TABLE internal.article ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.article ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE internal.docs_category ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
ALTER TABLE internal.footer ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.footer ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INTEGER, collaborator_permission_level INTEGER DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE)
|
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)
|
||||||
RETURNS BOOLEAN
|
AS $$
|
||||||
AS $$
|
|
||||||
DECLARE
|
DECLARE
|
||||||
_user_id UUID;
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
_has_access BOOLEAN;
|
|
||||||
BEGIN
|
BEGIN
|
||||||
_user_id := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
|
||||||
SELECT
|
SELECT
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT
|
SELECT
|
||||||
1
|
1
|
||||||
FROM
|
FROM
|
||||||
internal.website
|
internal.website AS w
|
||||||
WHERE
|
WHERE
|
||||||
id = website_id
|
w.id = user_has_website_access.website_id
|
||||||
AND user_id = _user_id) INTO _has_access;
|
AND w.user_id = _user_id) INTO has_access;
|
||||||
IF _has_access THEN
|
IF has_access THEN
|
||||||
RETURN _has_access;
|
RETURN;
|
||||||
END IF;
|
END IF;
|
||||||
SELECT
|
SELECT
|
||||||
EXISTS (
|
EXISTS (
|
||||||
@@ -45,24 +46,25 @@ BEGIN
|
|||||||
internal.collab c
|
internal.collab c
|
||||||
WHERE
|
WHERE
|
||||||
c.website_id = user_has_website_access.website_id
|
c.website_id = user_has_website_access.website_id
|
||||||
AND c.user_id = (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID
|
AND c.user_id = _user_id
|
||||||
AND c.permission_level >= user_has_website_access.required_permission
|
AND c.permission_level >= user_has_website_access.required_permission
|
||||||
AND (user_has_website_access.article_user_id IS NULL
|
AND (user_has_website_access.article_user_id IS NULL
|
||||||
OR (c.permission_level = 30
|
OR (c.permission_level = 30
|
||||||
OR user_has_website_access.article_user_id = _user_id))
|
OR user_has_website_access.article_user_id = _user_id))
|
||||||
AND (user_has_website_access.collaborator_permission_level IS NULL
|
AND (user_has_website_access.collaborator_permission_level IS NULL
|
||||||
OR (user_has_website_access.collaborator_user_id != _user_id
|
OR (user_has_website_access.collaborator_user_id != _user_id
|
||||||
AND user_has_website_access.collaborator_permission_level < 30))) INTO _has_access;
|
AND user_has_website_access.collaborator_permission_level < 30))) INTO has_access;
|
||||||
IF NOT _has_access AND user_has_website_access.raise_error THEN
|
IF NOT has_access AND user_has_website_access.raise_error THEN
|
||||||
RAISE insufficient_privilege
|
RAISE insufficient_privilege
|
||||||
USING message = 'You do not have the required permissions for this action.';
|
USING message = 'Insufficient permissions';
|
||||||
END IF;
|
END IF;
|
||||||
RETURN _has_access;
|
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
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;
|
||||||
|
|
||||||
CREATE POLICY view_user ON internal.user
|
CREATE POLICY view_user ON internal.user
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (TRUE);
|
USING (TRUE);
|
||||||
@@ -79,14 +81,6 @@ CREATE POLICY delete_website ON internal.website
|
|||||||
FOR DELETE
|
FOR DELETE
|
||||||
USING (internal.user_has_website_access (id, 40));
|
USING (internal.user_has_website_access (id, 40));
|
||||||
|
|
||||||
CREATE POLICY view_media ON internal.media
|
|
||||||
FOR SELECT
|
|
||||||
USING (internal.user_has_website_access (website_id, 10));
|
|
||||||
|
|
||||||
CREATE POLICY insert_media ON internal.media
|
|
||||||
FOR INSERT
|
|
||||||
WITH CHECK (internal.user_has_website_access (website_id, 20));
|
|
||||||
|
|
||||||
CREATE POLICY view_settings ON internal.settings
|
CREATE POLICY view_settings ON internal.settings
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (internal.user_has_website_access (website_id, 10));
|
USING (internal.user_has_website_access (website_id, 10));
|
||||||
@@ -127,6 +121,22 @@ CREATE POLICY insert_article ON internal.article
|
|||||||
FOR INSERT
|
FOR INSERT
|
||||||
WITH CHECK (internal.user_has_website_access (website_id, 20));
|
WITH CHECK (internal.user_has_website_access (website_id, 20));
|
||||||
|
|
||||||
|
CREATE POLICY view_categories ON internal.docs_category
|
||||||
|
FOR SELECT
|
||||||
|
USING (internal.user_has_website_access (website_id, 10));
|
||||||
|
|
||||||
|
CREATE POLICY update_category ON internal.docs_category
|
||||||
|
FOR UPDATE
|
||||||
|
USING (internal.user_has_website_access (website_id, 20));
|
||||||
|
|
||||||
|
CREATE POLICY delete_category ON internal.docs_category
|
||||||
|
FOR DELETE
|
||||||
|
USING (internal.user_has_website_access (website_id, 20, article_user_id => user_id));
|
||||||
|
|
||||||
|
CREATE POLICY insert_category ON internal.docs_category
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (internal.user_has_website_access (website_id, 20));
|
||||||
|
|
||||||
CREATE POLICY view_footer ON internal.footer
|
CREATE POLICY view_footer ON internal.footer
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (internal.user_has_website_access (website_id, 10));
|
USING (internal.user_has_website_access (website_id, 10));
|
||||||
@@ -135,6 +145,22 @@ CREATE POLICY update_footer ON internal.footer
|
|||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
USING (internal.user_has_website_access (website_id, 20));
|
USING (internal.user_has_website_access (website_id, 20));
|
||||||
|
|
||||||
|
CREATE POLICY view_legal_information ON internal.legal_information
|
||||||
|
FOR SELECT
|
||||||
|
USING (internal.user_has_website_access (website_id, 10));
|
||||||
|
|
||||||
|
CREATE POLICY update_legal_information ON internal.legal_information
|
||||||
|
FOR UPDATE
|
||||||
|
USING (internal.user_has_website_access (website_id, 30));
|
||||||
|
|
||||||
|
CREATE POLICY delete_legal_information ON internal.legal_information
|
||||||
|
FOR DELETE
|
||||||
|
USING (internal.user_has_website_access (website_id, 30));
|
||||||
|
|
||||||
|
CREATE POLICY insert_legal_information ON internal.legal_information
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (internal.user_has_website_access (website_id, 30));
|
||||||
|
|
||||||
CREATE POLICY view_collaborations ON internal.collab
|
CREATE POLICY view_collaborations ON internal.collab
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (internal.user_has_website_access (website_id, 10));
|
USING (internal.user_has_website_access (website_id, 10));
|
||||||
@@ -160,10 +186,6 @@ DROP POLICY delete_website ON internal.website;
|
|||||||
|
|
||||||
DROP POLICY update_website ON internal.website;
|
DROP POLICY update_website ON internal.website;
|
||||||
|
|
||||||
DROP POLICY view_media ON internal.media;
|
|
||||||
|
|
||||||
DROP POLICY insert_media ON internal.media;
|
|
||||||
|
|
||||||
DROP POLICY view_settings ON internal.settings;
|
DROP POLICY view_settings ON internal.settings;
|
||||||
|
|
||||||
DROP POLICY update_settings ON internal.settings;
|
DROP POLICY update_settings ON internal.settings;
|
||||||
@@ -184,10 +206,26 @@ DROP POLICY delete_article ON internal.article;
|
|||||||
|
|
||||||
DROP POLICY insert_article ON internal.article;
|
DROP POLICY insert_article ON internal.article;
|
||||||
|
|
||||||
|
DROP POLICY view_categories ON internal.docs_category;
|
||||||
|
|
||||||
|
DROP POLICY update_category ON internal.docs_category;
|
||||||
|
|
||||||
|
DROP POLICY delete_category ON internal.docs_category;
|
||||||
|
|
||||||
|
DROP POLICY insert_category ON internal.docs_category;
|
||||||
|
|
||||||
DROP POLICY view_footer ON internal.footer;
|
DROP POLICY view_footer ON internal.footer;
|
||||||
|
|
||||||
DROP POLICY update_footer ON internal.footer;
|
DROP POLICY update_footer ON internal.footer;
|
||||||
|
|
||||||
|
DROP POLICY insert_legal_information ON internal.legal_information;
|
||||||
|
|
||||||
|
DROP POLICY delete_legal_information ON internal.legal_information;
|
||||||
|
|
||||||
|
DROP POLICY update_legal_information ON internal.legal_information;
|
||||||
|
|
||||||
|
DROP POLICY view_legal_information ON internal.legal_information;
|
||||||
|
|
||||||
DROP POLICY view_collaborations ON internal.collab;
|
DROP POLICY view_collaborations ON internal.collab;
|
||||||
|
|
||||||
DROP POLICY insert_collaborations ON internal.collab;
|
DROP POLICY insert_collaborations ON internal.collab;
|
||||||
@@ -212,7 +250,11 @@ ALTER TABLE internal.home DISABLE ROW LEVEL SECURITY;
|
|||||||
|
|
||||||
ALTER TABLE internal.article DISABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.article DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE internal.docs_category DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
ALTER TABLE internal.footer DISABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.footer DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE internal.legal_information DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
ALTER TABLE internal.collab DISABLE ROW LEVEL SECURITY;
|
ALTER TABLE internal.collab DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
CREATE VIEW api.website_overview WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
w.id,
|
|
||||||
w.user_id,
|
|
||||||
w.content_type,
|
|
||||||
w.title,
|
|
||||||
s.accent_color_light_theme,
|
|
||||||
s.accent_color_dark_theme,
|
|
||||||
s.favicon_image,
|
|
||||||
h.logo_type,
|
|
||||||
h.logo_text,
|
|
||||||
h.logo_image,
|
|
||||||
ho.main_content,
|
|
||||||
f.additional_text,
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content
|
|
||||||
)
|
|
||||||
)
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
) AS articles
|
|
||||||
FROM
|
|
||||||
internal.website w
|
|
||||||
JOIN internal.settings s ON w.id = s.website_id
|
|
||||||
JOIN internal.header h ON w.id = h.website_id
|
|
||||||
JOIN internal.home ho ON w.id = ho.website_id
|
|
||||||
JOIN internal.footer f ON w.id = f.website_id;
|
|
||||||
|
|
||||||
GRANT SELECT ON api.website_overview TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP VIEW api.website_overview;
|
|
||||||
|
|
||||||
@@ -2,35 +2,35 @@
|
|||||||
CREATE FUNCTION internal.update_last_modified ()
|
CREATE FUNCTION internal.update_last_modified ()
|
||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
AS $$
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
BEGIN
|
BEGIN
|
||||||
NEW.last_modified_at = CLOCK_TIMESTAMP();
|
IF (NOT EXISTS (
|
||||||
NEW.last_modified_by = (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
internal.user
|
||||||
|
WHERE
|
||||||
|
id = _user_id)) THEN
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END IF;
|
||||||
|
IF TG_OP != 'DELETE' THEN
|
||||||
|
NEW.last_modified_at = CLOCK_TIMESTAMP();
|
||||||
|
NEW.last_modified_by = _user_id;
|
||||||
|
END IF;
|
||||||
IF TG_TABLE_NAME != 'website' THEN
|
IF TG_TABLE_NAME != 'website' THEN
|
||||||
UPDATE
|
UPDATE
|
||||||
internal.website
|
internal.website
|
||||||
SET
|
SET
|
||||||
last_modified_at = NEW.last_modified_at,
|
last_modified_at = CLOCK_TIMESTAMP(),
|
||||||
last_modified_by = NEW.last_modified_by
|
last_modified_by = _user_id
|
||||||
WHERE
|
WHERE
|
||||||
id = CASE WHEN TG_TABLE_NAME = 'settings' THEN
|
id = COALESCE(NEW.website_id, OLD.website_id);
|
||||||
NEW.website_id
|
|
||||||
WHEN TG_TABLE_NAME = 'header' THEN
|
|
||||||
NEW.website_id
|
|
||||||
WHEN TG_TABLE_NAME = 'home' THEN
|
|
||||||
NEW.website_id
|
|
||||||
WHEN TG_TABLE_NAME = 'article' THEN
|
|
||||||
NEW.website_id
|
|
||||||
WHEN TG_TABLE_NAME = 'footer' THEN
|
|
||||||
NEW.website_id
|
|
||||||
WHEN TG_TABLE_NAME = 'collab' THEN
|
|
||||||
NEW.website_id
|
|
||||||
END;
|
|
||||||
END IF;
|
END IF;
|
||||||
RETURN NEW;
|
RETURN COALESCE(NEW, OLD);
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql;
|
||||||
SECURITY DEFINER;
|
|
||||||
|
|
||||||
CREATE TRIGGER update_website_last_modified
|
CREATE TRIGGER update_website_last_modified
|
||||||
BEFORE UPDATE ON internal.website
|
BEFORE UPDATE ON internal.website
|
||||||
@@ -57,13 +57,23 @@ CREATE TRIGGER update_article_last_modified
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.update_last_modified ();
|
EXECUTE FUNCTION internal.update_last_modified ();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_docs_category_modified
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE ON internal.docs_category
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.update_last_modified ();
|
||||||
|
|
||||||
CREATE TRIGGER update_footer_last_modified
|
CREATE TRIGGER update_footer_last_modified
|
||||||
BEFORE UPDATE ON internal.footer
|
BEFORE UPDATE ON internal.footer
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.update_last_modified ();
|
EXECUTE FUNCTION internal.update_last_modified ();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_legal_information_last_modified
|
||||||
|
BEFORE INSERT OR DELETE ON internal.legal_information
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.update_last_modified ();
|
||||||
|
|
||||||
CREATE TRIGGER update_collab_last_modified
|
CREATE TRIGGER update_collab_last_modified
|
||||||
BEFORE UPDATE ON internal.collab
|
BEFORE INSERT OR UPDATE OR DELETE ON internal.collab
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION internal.update_last_modified ();
|
EXECUTE FUNCTION internal.update_last_modified ();
|
||||||
|
|
||||||
@@ -78,8 +88,12 @@ DROP TRIGGER update_home_last_modified ON internal.home;
|
|||||||
|
|
||||||
DROP TRIGGER update_article_last_modified ON internal.article;
|
DROP TRIGGER update_article_last_modified ON internal.article;
|
||||||
|
|
||||||
|
DROP TRIGGER update_docs_category_modified ON internal.docs_category;
|
||||||
|
|
||||||
DROP TRIGGER update_footer_last_modified ON internal.footer;
|
DROP TRIGGER update_footer_last_modified ON internal.footer;
|
||||||
|
|
||||||
|
DROP TRIGGER update_legal_information_last_modified ON internal.legal_information;
|
||||||
|
|
||||||
DROP TRIGGER update_collab_last_modified ON internal.collab;
|
DROP TRIGGER update_collab_last_modified ON internal.collab;
|
||||||
|
|
||||||
DROP FUNCTION internal.update_last_modified ();
|
DROP FUNCTION internal.update_last_modified ();
|
||||||
|
|||||||
@@ -3,22 +3,20 @@ CREATE FUNCTION internal.check_user_not_website_owner ()
|
|||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (
|
IF (EXISTS (
|
||||||
SELECT
|
SELECT
|
||||||
1
|
1
|
||||||
FROM
|
FROM
|
||||||
internal.website
|
internal.website AS w
|
||||||
WHERE
|
WHERE
|
||||||
id = NEW.website_id
|
w.id = NEW.website_id AND w.user_id = NEW.user_id)) THEN
|
||||||
AND user_id = NEW.user_id) THEN
|
RAISE foreign_key_violation
|
||||||
RAISE foreign_key_violation
|
USING message = 'User cannot be added as a collaborator to their own website';
|
||||||
USING message = 'User cannot be added as a collaborator to their own website';
|
END IF;
|
||||||
END IF;
|
RETURN NULL;
|
||||||
RETURN NEW;
|
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql;
|
||||||
SECURITY DEFINER;
|
|
||||||
|
|
||||||
CREATE CONSTRAINT TRIGGER check_user_not_website_owner
|
CREATE CONSTRAINT TRIGGER check_user_not_website_owner
|
||||||
AFTER INSERT ON internal.collab
|
AFTER INSERT ON internal.collab
|
||||||
|
|||||||
@@ -10,25 +10,27 @@ DECLARE
|
|||||||
_original_filename TEXT := _headers ->> 'x-original-filename';
|
_original_filename TEXT := _headers ->> 'x-original-filename';
|
||||||
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
|
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
|
||||||
_max_file_size INT := 5 * 1024 * 1024;
|
_max_file_size INT := 5 * 1024 * 1024;
|
||||||
|
_has_access BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
|
_has_access = internal.user_has_website_access (_website_id, 20);
|
||||||
IF OCTET_LENGTH($1) = 0 THEN
|
IF OCTET_LENGTH($1) = 0 THEN
|
||||||
RAISE invalid_parameter_value
|
RAISE invalid_parameter_value
|
||||||
USING message = 'No file data was provided';
|
USING message = 'No file data was provided';
|
||||||
|
ELSIF (_mimetype IS NULL
|
||||||
|
OR _mimetype NOT IN (
|
||||||
|
SELECT
|
||||||
|
UNNEST(_allowed_mimetypes))) THEN
|
||||||
|
RAISE invalid_parameter_value
|
||||||
|
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
|
||||||
|
ELSIF OCTET_LENGTH($1) > _max_file_size THEN
|
||||||
|
RAISE program_limit_exceeded
|
||||||
|
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
|
||||||
|
ELSE
|
||||||
|
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
||||||
|
VALUES (_website_id, $1, _mimetype, _original_filename)
|
||||||
|
RETURNING
|
||||||
|
id INTO file_id;
|
||||||
END IF;
|
END IF;
|
||||||
IF _mimetype IS NULL OR _mimetype NOT IN (
|
|
||||||
SELECT
|
|
||||||
UNNEST(_allowed_mimetypes)) THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Invalid MIME type. Allowed types are: png, svg, jpg, webp';
|
|
||||||
END IF;
|
|
||||||
IF OCTET_LENGTH($1) > _max_file_size THEN
|
|
||||||
RAISE program_limit_exceeded
|
|
||||||
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
|
|
||||||
END IF;
|
|
||||||
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
|
||||||
VALUES (_website_id, $1, _mimetype, _original_filename)
|
|
||||||
RETURNING
|
|
||||||
id INTO file_id;
|
|
||||||
END;
|
END;
|
||||||
$$
|
$$
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
@@ -46,7 +48,7 @@ BEGIN
|
|||||||
'{ "Content-Disposition": "inline; filename=\"%s\"" },'
|
'{ "Content-Disposition": "inline; filename=\"%s\"" },'
|
||||||
'{ "Cache-Control": "max-age=259200" }]', m.mimetype, m.original_name)
|
'{ "Cache-Control": "max-age=259200" }]', m.mimetype, m.original_name)
|
||||||
FROM
|
FROM
|
||||||
internal.media m
|
internal.media AS m
|
||||||
WHERE
|
WHERE
|
||||||
m.id = retrieve_file.id INTO _headers;
|
m.id = retrieve_file.id INTO _headers;
|
||||||
PERFORM
|
PERFORM
|
||||||
@@ -70,6 +72,8 @@ SECURITY DEFINER;
|
|||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
|
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO anon;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO authenticated_user;
|
GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO authenticated_user;
|
||||||
|
|
||||||
-- migrate:down
|
-- migrate:down
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
ALTER TABLE internal.website
|
|
||||||
ADD COLUMN title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED;
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW api.website WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
user_id,
|
|
||||||
content_type,
|
|
||||||
title,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by,
|
|
||||||
title_search -- New column
|
|
||||||
FROM
|
|
||||||
internal.website;
|
|
||||||
|
|
||||||
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
|
|
||||||
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
ADD COLUMN title_description_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(title, '') || ' ' || COALESCE(meta_description, ''))) STORED;
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW api.article WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
meta_description,
|
|
||||||
meta_author,
|
|
||||||
cover_image,
|
|
||||||
publication_date,
|
|
||||||
main_content,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by,
|
|
||||||
title_description_search -- New column
|
|
||||||
FROM
|
|
||||||
internal.article;
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP VIEW api.article;
|
|
||||||
|
|
||||||
CREATE VIEW api.article WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
meta_description,
|
|
||||||
meta_author,
|
|
||||||
cover_image,
|
|
||||||
publication_date,
|
|
||||||
main_content,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
|
||||||
internal.article;
|
|
||||||
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
DROP COLUMN title_description_search;
|
|
||||||
|
|
||||||
DROP VIEW api.website;
|
|
||||||
|
|
||||||
CREATE VIEW api.website WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
user_id,
|
|
||||||
content_type,
|
|
||||||
title,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by
|
|
||||||
FROM
|
|
||||||
internal.website;
|
|
||||||
|
|
||||||
ALTER TABLE internal.website
|
|
||||||
DROP COLUMN title_search;
|
|
||||||
|
|
||||||
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
|
|
||||||
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
CREATE OR REPLACE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
|
|
||||||
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
|
|
||||||
_mimetype TEXT := _headers ->> 'x-mimetype';
|
|
||||||
_original_filename TEXT := _headers ->> 'x-original-filename';
|
|
||||||
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
|
|
||||||
_max_file_size INT := 5 * 1024 * 1024;
|
|
||||||
BEGIN
|
|
||||||
IF OCTET_LENGTH($1) = 0 THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'No file data was provided';
|
|
||||||
END IF;
|
|
||||||
IF _mimetype IS NULL OR _mimetype NOT IN (
|
|
||||||
SELECT
|
|
||||||
UNNEST(_allowed_mimetypes)) THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
|
|
||||||
END IF;
|
|
||||||
IF OCTET_LENGTH($1) > _max_file_size THEN
|
|
||||||
RAISE program_limit_exceeded
|
|
||||||
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
|
|
||||||
END IF;
|
|
||||||
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
|
||||||
VALUES (_website_id, $1, _mimetype, _original_filename)
|
|
||||||
RETURNING
|
|
||||||
id INTO file_id;
|
|
||||||
END;
|
|
||||||
$$
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER;
|
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP FUNCTION api.upload_file (BYTEA);
|
|
||||||
|
|
||||||
CREATE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
|
|
||||||
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
|
|
||||||
_mimetype TEXT := _headers ->> 'x-mimetype';
|
|
||||||
_original_filename TEXT := _headers ->> 'x-original-filename';
|
|
||||||
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
|
|
||||||
_max_file_size INT := 5 * 1024 * 1024;
|
|
||||||
BEGIN
|
|
||||||
IF OCTET_LENGTH($1) = 0 THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'No file data was provided';
|
|
||||||
END IF;
|
|
||||||
IF _mimetype IS NULL OR _mimetype NOT IN (
|
|
||||||
SELECT
|
|
||||||
UNNEST(_allowed_mimetypes)) THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Invalid MIME type. Allowed types are: png, svg, jpg, webp';
|
|
||||||
END IF;
|
|
||||||
IF OCTET_LENGTH($1) > _max_file_size THEN
|
|
||||||
RAISE program_limit_exceeded
|
|
||||||
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
|
|
||||||
END IF;
|
|
||||||
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
|
||||||
VALUES (_website_id, $1, _mimetype, _original_filename)
|
|
||||||
RETURNING
|
|
||||||
id INTO file_id;
|
|
||||||
END;
|
|
||||||
$$
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER;
|
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
|
|
||||||
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
CREATE TABLE internal.docs_category (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
|
||||||
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
|
|
||||||
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
|
||||||
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
|
|
||||||
category_weight INTEGER CHECK (category_weight >= 0) NOT NULL,
|
|
||||||
UNIQUE (website_id, category_name),
|
|
||||||
UNIQUE (website_id, category_weight)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE internal.website
|
|
||||||
ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
ADD COLUMN category UUID REFERENCES internal.docs_category (id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
ALTER COLUMN user_id SET DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
|
||||||
|
|
||||||
ALTER TABLE internal.docs_category ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY view_categories ON internal.docs_category
|
|
||||||
FOR SELECT
|
|
||||||
USING (internal.user_has_website_access (website_id, 10));
|
|
||||||
|
|
||||||
CREATE POLICY update_category ON internal.docs_category
|
|
||||||
FOR UPDATE
|
|
||||||
USING (internal.user_has_website_access (website_id, 20));
|
|
||||||
|
|
||||||
CREATE POLICY delete_category ON internal.docs_category
|
|
||||||
FOR DELETE
|
|
||||||
USING (internal.user_has_website_access (website_id, 20, article_user_id => user_id));
|
|
||||||
|
|
||||||
CREATE POLICY insert_category ON internal.docs_category
|
|
||||||
FOR INSERT
|
|
||||||
WITH CHECK (internal.user_has_website_access (website_id, 20));
|
|
||||||
|
|
||||||
CREATE VIEW api.docs_category WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
category_name,
|
|
||||||
category_weight
|
|
||||||
FROM
|
|
||||||
internal.docs_category;
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW api.article WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
meta_description,
|
|
||||||
meta_author,
|
|
||||||
cover_image,
|
|
||||||
publication_date,
|
|
||||||
main_content,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by,
|
|
||||||
title_description_search,
|
|
||||||
category -- New column
|
|
||||||
FROM
|
|
||||||
internal.article;
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.docs_category TO authenticated_user;
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.docs_category TO authenticated_user;
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP POLICY view_categories ON internal.docs_category;
|
|
||||||
|
|
||||||
DROP POLICY update_category ON internal.docs_category;
|
|
||||||
|
|
||||||
DROP POLICY delete_category ON internal.docs_category;
|
|
||||||
|
|
||||||
DROP POLICY insert_category ON internal.docs_category;
|
|
||||||
|
|
||||||
DROP VIEW api.article;
|
|
||||||
|
|
||||||
CREATE VIEW api.article WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
meta_description,
|
|
||||||
meta_author,
|
|
||||||
cover_image,
|
|
||||||
publication_date,
|
|
||||||
main_content,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by,
|
|
||||||
title_description_search
|
|
||||||
FROM
|
|
||||||
internal.article;
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
|
|
||||||
|
|
||||||
DROP VIEW api.docs_category;
|
|
||||||
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
DROP COLUMN category;
|
|
||||||
|
|
||||||
DROP TABLE internal.docs_category;
|
|
||||||
|
|
||||||
ALTER TABLE internal.website
|
|
||||||
DROP COLUMN is_published;
|
|
||||||
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
ALTER COLUMN user_id DROP DEFAULT;
|
|
||||||
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
CREATE OR REPLACE VIEW api.website_overview WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
w.id,
|
|
||||||
w.user_id,
|
|
||||||
w.content_type,
|
|
||||||
w.title,
|
|
||||||
s.accent_color_light_theme,
|
|
||||||
s.accent_color_dark_theme,
|
|
||||||
s.favicon_image,
|
|
||||||
h.logo_type,
|
|
||||||
h.logo_text,
|
|
||||||
h.logo_image,
|
|
||||||
ho.main_content,
|
|
||||||
f.additional_text,
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
)
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
) AS articles,
|
|
||||||
CASE WHEN w.content_type = 'Docs' THEN
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_OBJECT_AGG(
|
|
||||||
COALESCE(
|
|
||||||
category_name, 'Uncategorized'
|
|
||||||
), articles
|
|
||||||
)
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight AS category_weight,
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
) AS articles
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
LEFT JOIN internal.docs_category dc ON a.category = dc.id
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
GROUP BY
|
|
||||||
dc.id,
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight
|
|
||||||
ORDER BY
|
|
||||||
category_weight DESC
|
|
||||||
) AS categorized_articles)
|
|
||||||
ELSE
|
|
||||||
NULL
|
|
||||||
END AS categorized_articles
|
|
||||||
FROM
|
|
||||||
internal.website w
|
|
||||||
JOIN internal.settings s ON w.id = s.website_id
|
|
||||||
JOIN internal.header h ON w.id = h.website_id
|
|
||||||
JOIN internal.home ho ON w.id = ho.website_id
|
|
||||||
JOIN internal.footer f ON w.id = f.website_id;
|
|
||||||
|
|
||||||
GRANT SELECT ON api.website_overview TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP VIEW api.website_overview;
|
|
||||||
|
|
||||||
CREATE VIEW api.website_overview WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
w.id,
|
|
||||||
w.user_id,
|
|
||||||
w.content_type,
|
|
||||||
w.title,
|
|
||||||
s.accent_color_light_theme,
|
|
||||||
s.accent_color_dark_theme,
|
|
||||||
s.favicon_image,
|
|
||||||
h.logo_type,
|
|
||||||
h.logo_text,
|
|
||||||
h.logo_image,
|
|
||||||
ho.main_content,
|
|
||||||
f.additional_text,
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content
|
|
||||||
)
|
|
||||||
)
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
) AS articles
|
|
||||||
FROM
|
|
||||||
internal.website w
|
|
||||||
JOIN internal.settings s ON w.id = s.website_id
|
|
||||||
JOIN internal.header h ON w.id = h.website_id
|
|
||||||
JOIN internal.home ho ON w.id = ho.website_id
|
|
||||||
JOIN internal.footer f ON w.id = f.website_id;
|
|
||||||
|
|
||||||
GRANT SELECT ON api.website_overview TO authenticated_user;
|
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
ADD COLUMN article_weight INTEGER CHECK (article_weight IS NULL
|
|
||||||
OR article_weight >= 0);
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW api.article WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
meta_description,
|
|
||||||
meta_author,
|
|
||||||
cover_image,
|
|
||||||
publication_date,
|
|
||||||
main_content,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by,
|
|
||||||
title_description_search,
|
|
||||||
category,
|
|
||||||
article_weight -- New column
|
|
||||||
FROM
|
|
||||||
internal.article;
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP VIEW api.article;
|
|
||||||
|
|
||||||
CREATE VIEW api.article WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
website_id,
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
meta_description,
|
|
||||||
meta_author,
|
|
||||||
cover_image,
|
|
||||||
publication_date,
|
|
||||||
main_content,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by,
|
|
||||||
title_description_search,
|
|
||||||
category
|
|
||||||
FROM
|
|
||||||
internal.article;
|
|
||||||
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
DROP COLUMN article_weight;
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
|
|
||||||
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
CREATE OR REPLACE VIEW api.website_overview WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
w.id,
|
|
||||||
w.user_id,
|
|
||||||
w.content_type,
|
|
||||||
w.title,
|
|
||||||
s.accent_color_light_theme,
|
|
||||||
s.accent_color_dark_theme,
|
|
||||||
s.favicon_image,
|
|
||||||
h.logo_type,
|
|
||||||
h.logo_text,
|
|
||||||
h.logo_image,
|
|
||||||
ho.main_content,
|
|
||||||
f.additional_text,
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
)
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
) AS articles,
|
|
||||||
CASE WHEN w.content_type = 'Docs' THEN
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_OBJECT_AGG(
|
|
||||||
COALESCE(
|
|
||||||
category_name, 'Uncategorized'
|
|
||||||
), articles
|
|
||||||
)
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight AS category_weight,
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
) AS articles
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
LEFT JOIN internal.docs_category dc ON a.category = dc.id
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
GROUP BY
|
|
||||||
dc.id,
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight
|
|
||||||
ORDER BY
|
|
||||||
category_weight DESC NULLS LAST
|
|
||||||
) AS categorized_articles)
|
|
||||||
ELSE
|
|
||||||
NULL
|
|
||||||
END AS categorized_articles
|
|
||||||
FROM
|
|
||||||
internal.website w
|
|
||||||
JOIN internal.settings s ON w.id = s.website_id
|
|
||||||
JOIN internal.header h ON w.id = h.website_id
|
|
||||||
JOIN internal.home ho ON w.id = ho.website_id
|
|
||||||
JOIN internal.footer f ON w.id = f.website_id;
|
|
||||||
|
|
||||||
GRANT SELECT ON api.website_overview TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP VIEW api.website_overview;
|
|
||||||
|
|
||||||
CREATE VIEW api.website_overview WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
w.id,
|
|
||||||
w.user_id,
|
|
||||||
w.content_type,
|
|
||||||
w.title,
|
|
||||||
s.accent_color_light_theme,
|
|
||||||
s.accent_color_dark_theme,
|
|
||||||
s.favicon_image,
|
|
||||||
h.logo_type,
|
|
||||||
h.logo_text,
|
|
||||||
h.logo_image,
|
|
||||||
ho.main_content,
|
|
||||||
f.additional_text,
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
)
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
) AS articles,
|
|
||||||
CASE WHEN w.content_type = 'Docs' THEN
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_OBJECT_AGG(
|
|
||||||
COALESCE(
|
|
||||||
category_name, 'Uncategorized'
|
|
||||||
), articles
|
|
||||||
)
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight AS category_weight,
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
) AS articles
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
LEFT JOIN internal.docs_category dc ON a.category = dc.id
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
GROUP BY
|
|
||||||
dc.id,
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight
|
|
||||||
ORDER BY
|
|
||||||
category_weight DESC
|
|
||||||
) AS categorized_articles)
|
|
||||||
ELSE
|
|
||||||
NULL
|
|
||||||
END AS categorized_articles
|
|
||||||
FROM
|
|
||||||
internal.website w
|
|
||||||
JOIN internal.settings s ON w.id = s.website_id
|
|
||||||
JOIN internal.header h ON w.id = h.website_id
|
|
||||||
JOIN internal.home ho ON w.id = ho.website_id
|
|
||||||
JOIN internal.footer f ON w.id = f.website_id;
|
|
||||||
|
|
||||||
GRANT SELECT ON api.website_overview TO authenticated_user;
|
|
||||||
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
CREATE OR REPLACE VIEW api.website_overview WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
w.id,
|
|
||||||
w.user_id,
|
|
||||||
w.content_type,
|
|
||||||
w.title,
|
|
||||||
s.accent_color_light_theme,
|
|
||||||
s.accent_color_dark_theme,
|
|
||||||
s.favicon_image,
|
|
||||||
h.logo_type,
|
|
||||||
h.logo_text,
|
|
||||||
h.logo_image,
|
|
||||||
ho.main_content,
|
|
||||||
f.additional_text,
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
)
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
) AS articles,
|
|
||||||
CASE WHEN w.content_type = 'Docs' THEN
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_OBJECT_AGG(
|
|
||||||
COALESCE(
|
|
||||||
category_name, 'Uncategorized'
|
|
||||||
), articles
|
|
||||||
)
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight AS category_weight,
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
) ORDER BY a.article_weight DESC NULLS LAST
|
|
||||||
) AS articles
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
LEFT JOIN internal.docs_category dc ON a.category = dc.id
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
GROUP BY
|
|
||||||
dc.id,
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight
|
|
||||||
ORDER BY
|
|
||||||
category_weight DESC NULLS LAST
|
|
||||||
) AS categorized_articles)
|
|
||||||
ELSE
|
|
||||||
NULL
|
|
||||||
END AS categorized_articles
|
|
||||||
FROM
|
|
||||||
internal.website w
|
|
||||||
JOIN internal.settings s ON w.id = s.website_id
|
|
||||||
JOIN internal.header h ON w.id = h.website_id
|
|
||||||
JOIN internal.home ho ON w.id = ho.website_id
|
|
||||||
JOIN internal.footer f ON w.id = f.website_id;
|
|
||||||
|
|
||||||
GRANT SELECT ON api.website_overview TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP VIEW api.website_overview;
|
|
||||||
|
|
||||||
CREATE VIEW api.website_overview WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
w.id,
|
|
||||||
w.user_id,
|
|
||||||
w.content_type,
|
|
||||||
w.title,
|
|
||||||
s.accent_color_light_theme,
|
|
||||||
s.accent_color_dark_theme,
|
|
||||||
s.favicon_image,
|
|
||||||
h.logo_type,
|
|
||||||
h.logo_text,
|
|
||||||
h.logo_image,
|
|
||||||
ho.main_content,
|
|
||||||
f.additional_text,
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
)
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
) AS articles,
|
|
||||||
CASE WHEN w.content_type = 'Docs' THEN
|
|
||||||
(
|
|
||||||
SELECT
|
|
||||||
JSON_OBJECT_AGG(
|
|
||||||
COALESCE(
|
|
||||||
category_name, 'Uncategorized'
|
|
||||||
), articles
|
|
||||||
)
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight AS category_weight,
|
|
||||||
JSON_AGG(
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'id', a.id, 'title', a.title, 'meta_description', a.meta_description, 'meta_author', a.meta_author, 'cover_image', a.cover_image, 'publication_date', a.publication_date, 'main_content', a.main_content, 'created_at', a.created_at, 'last_modified_at', a.last_modified_at
|
|
||||||
)
|
|
||||||
) AS articles
|
|
||||||
FROM
|
|
||||||
internal.article a
|
|
||||||
LEFT JOIN internal.docs_category dc ON a.category = dc.id
|
|
||||||
WHERE
|
|
||||||
a.website_id = w.id
|
|
||||||
GROUP BY
|
|
||||||
dc.id,
|
|
||||||
dc.category_name,
|
|
||||||
dc.category_weight
|
|
||||||
ORDER BY
|
|
||||||
category_weight DESC NULLS LAST
|
|
||||||
) AS categorized_articles)
|
|
||||||
ELSE
|
|
||||||
NULL
|
|
||||||
END AS categorized_articles
|
|
||||||
FROM
|
|
||||||
internal.website w
|
|
||||||
JOIN internal.settings s ON w.id = s.website_id
|
|
||||||
JOIN internal.header h ON w.id = h.website_id
|
|
||||||
JOIN internal.home ho ON w.id = ho.website_id
|
|
||||||
JOIN internal.footer f ON w.id = f.website_id;
|
|
||||||
|
|
||||||
GRANT SELECT ON api.website_overview TO authenticated_user;
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
ALTER COLUMN publication_date DROP NOT NULL;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
UPDATE
|
|
||||||
internal.article
|
|
||||||
SET
|
|
||||||
publication_date = CURRENT_DATE
|
|
||||||
WHERE
|
|
||||||
publication_date IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE internal.article
|
|
||||||
ALTER COLUMN publication_date SET NOT NULL;
|
|
||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
CREATE OR REPLACE VIEW api.website WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
user_id,
|
|
||||||
content_type,
|
|
||||||
title,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by,
|
|
||||||
title_search,
|
|
||||||
is_published -- New column
|
|
||||||
FROM
|
|
||||||
internal.website;
|
|
||||||
|
|
||||||
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP VIEW api.website;
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW api.website WITH ( security_invoker = ON
|
|
||||||
) AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
user_id,
|
|
||||||
content_type,
|
|
||||||
title,
|
|
||||||
created_at,
|
|
||||||
last_modified_at,
|
|
||||||
last_modified_by,
|
|
||||||
title_search
|
|
||||||
FROM
|
|
||||||
internal.website;
|
|
||||||
|
|
||||||
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
|
|
||||||
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
-- migrate:up
|
|
||||||
CREATE OR REPLACE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
|
|
||||||
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
|
|
||||||
_mimetype TEXT := _headers ->> 'x-mimetype';
|
|
||||||
_original_filename TEXT := _headers ->> 'x-original-filename';
|
|
||||||
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
|
|
||||||
_max_file_size INT := 5 * 1024 * 1024;
|
|
||||||
_has_access BOOLEAN;
|
|
||||||
BEGIN
|
|
||||||
_has_access = internal.user_has_website_access (_website_id, 20);
|
|
||||||
IF OCTET_LENGTH($1) = 0 THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'No file data was provided';
|
|
||||||
END IF;
|
|
||||||
IF _mimetype IS NULL OR _mimetype NOT IN (
|
|
||||||
SELECT
|
|
||||||
UNNEST(_allowed_mimetypes)) THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
|
|
||||||
END IF;
|
|
||||||
IF OCTET_LENGTH($1) > _max_file_size THEN
|
|
||||||
RAISE program_limit_exceeded
|
|
||||||
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
|
|
||||||
END IF;
|
|
||||||
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
|
||||||
VALUES (_website_id, $1, _mimetype, _original_filename)
|
|
||||||
RETURNING
|
|
||||||
id INTO file_id;
|
|
||||||
END;
|
|
||||||
$$
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER;
|
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
|
|
||||||
|
|
||||||
-- migrate:down
|
|
||||||
DROP FUNCTION api.upload_file (BYTEA);
|
|
||||||
|
|
||||||
CREATE FUNCTION api.upload_file (BYTEA, OUT file_id UUID)
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
_headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON;
|
|
||||||
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
|
|
||||||
_mimetype TEXT := _headers ->> 'x-mimetype';
|
|
||||||
_original_filename TEXT := _headers ->> 'x-original-filename';
|
|
||||||
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp'];
|
|
||||||
_max_file_size INT := 5 * 1024 * 1024;
|
|
||||||
BEGIN
|
|
||||||
IF OCTET_LENGTH($1) = 0 THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'No file data was provided';
|
|
||||||
END IF;
|
|
||||||
IF _mimetype IS NULL OR _mimetype NOT IN (
|
|
||||||
SELECT
|
|
||||||
UNNEST(_allowed_mimetypes)) THEN
|
|
||||||
RAISE invalid_parameter_value
|
|
||||||
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
|
|
||||||
END IF;
|
|
||||||
IF OCTET_LENGTH($1) > _max_file_size THEN
|
|
||||||
RAISE program_limit_exceeded
|
|
||||||
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
|
|
||||||
END IF;
|
|
||||||
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
|
|
||||||
VALUES (_website_id, $1, _mimetype, _original_filename)
|
|
||||||
RETURNING
|
|
||||||
id INTO file_id;
|
|
||||||
END;
|
|
||||||
$$
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER;
|
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
|
|
||||||
|
|
||||||
149
rest-api/db/migrations/20240911070907_change_log.sql
Normal file
149
rest-api/db/migrations/20240911070907_change_log.sql
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
-- migrate:up
|
||||||
|
CREATE EXTENSION hstore;
|
||||||
|
|
||||||
|
CREATE TABLE internal.change_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
|
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
|
||||||
|
username VARCHAR(16) NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'username'),
|
||||||
|
tstamp TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
||||||
|
table_name TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
old_value HSTORE,
|
||||||
|
new_value HSTORE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE FUNCTION internal.track_changes ()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
_website_id UUID;
|
||||||
|
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
|
||||||
|
BEGIN
|
||||||
|
IF (NOT EXISTS (
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
internal.user
|
||||||
|
WHERE
|
||||||
|
id = _user_id) OR (to_jsonb (OLD.*) - 'last_modified_at' - 'last_modified_by') = (to_jsonb (NEW.*) - 'last_modified_at' - 'last_modified_by')) THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
IF TG_TABLE_NAME = 'website' THEN
|
||||||
|
_website_id := NEW.id;
|
||||||
|
ELSE
|
||||||
|
_website_id := COALESCE(NEW.website_id, OLD.website_id);
|
||||||
|
END IF;
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
INSERT INTO internal.change_log (website_id, table_name, operation, new_value)
|
||||||
|
VALUES (_website_id, TG_TABLE_NAME, TG_OP, HSTORE (NEW));
|
||||||
|
ELSIF (TG_OP = 'UPDATE'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
internal.website
|
||||||
|
WHERE
|
||||||
|
id = _website_id)) THEN
|
||||||
|
INSERT INTO internal.change_log (website_id, table_name, operation, old_value, new_value)
|
||||||
|
VALUES (_website_id, TG_TABLE_NAME, TG_OP, HSTORE (OLD) - HSTORE (NEW), HSTORE (NEW) - HSTORE (OLD));
|
||||||
|
ELSIF (TG_OP = 'DELETE'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
internal.website
|
||||||
|
WHERE
|
||||||
|
id = _website_id)) THEN
|
||||||
|
INSERT INTO internal.change_log (website_id, table_name, operation, old_value)
|
||||||
|
VALUES (_website_id, TG_TABLE_NAME, TG_OP, HSTORE (OLD));
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER website_track_changes
|
||||||
|
AFTER UPDATE ON internal.website
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER settings_track_changes
|
||||||
|
AFTER UPDATE ON internal.settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER header_track_changes
|
||||||
|
AFTER UPDATE ON internal.header
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER home_track_changes
|
||||||
|
AFTER UPDATE ON internal.home
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER article_track_changes
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON internal.article
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER docs_category_track_changes
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON internal.docs_category
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER footer_track_changes
|
||||||
|
AFTER UPDATE ON internal.footer
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER legal_information_track_changes
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE TRIGGER collab_track_changes
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON internal.collab
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
CREATE VIEW api.change_log WITH ( security_invoker = ON
|
||||||
|
) AS
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM
|
||||||
|
internal.change_log;
|
||||||
|
|
||||||
|
GRANT SELECT ON internal.change_log TO authenticated_user;
|
||||||
|
|
||||||
|
GRANT SELECT ON api.change_log TO authenticated_user;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP TRIGGER website_track_changes ON internal.website;
|
||||||
|
|
||||||
|
DROP TRIGGER settings_track_changes ON internal.settings;
|
||||||
|
|
||||||
|
DROP TRIGGER header_track_changes ON internal.header;
|
||||||
|
|
||||||
|
DROP TRIGGER home_track_changes ON internal.home;
|
||||||
|
|
||||||
|
DROP TRIGGER article_track_changes ON internal.article;
|
||||||
|
|
||||||
|
DROP TRIGGER docs_category_track_changes ON internal.docs_category;
|
||||||
|
|
||||||
|
DROP TRIGGER footer_track_changes ON internal.footer;
|
||||||
|
|
||||||
|
DROP TRIGGER legal_information_track_changes ON internal.legal_information;
|
||||||
|
|
||||||
|
DROP TRIGGER collab_track_changes ON internal.collab;
|
||||||
|
|
||||||
|
DROP FUNCTION internal.track_changes ();
|
||||||
|
|
||||||
|
DROP VIEW api.change_log;
|
||||||
|
|
||||||
|
DROP TABLE internal.change_log;
|
||||||
|
|
||||||
|
DROP EXTENSION hstore;
|
||||||
|
|
||||||
556
web-app/package-lock.json
generated
556
web-app/package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "web-app",
|
"name": "web-app",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"github-slugger": "2.0.0",
|
"fast-diff": "1.3.0",
|
||||||
"highlight.js": "11.10.0",
|
"highlight.js": "11.10.0",
|
||||||
"isomorphic-dompurify": "2.14.0",
|
"isomorphic-dompurify": "2.14.0",
|
||||||
"marked": "14.0.0",
|
"marked": "14.0.0",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"eslint-config-prettier": "9.1.0",
|
"eslint-config-prettier": "9.1.0",
|
||||||
"eslint-plugin-svelte": "2.43.0",
|
"eslint-plugin-svelte": "2.43.0",
|
||||||
"globals": "15.9.0",
|
"globals": "15.9.0",
|
||||||
|
"pg-to-ts": "4.1.1",
|
||||||
"prettier": "3.3.3",
|
"prettier": "3.3.3",
|
||||||
"prettier-plugin-svelte": "3.2.6",
|
"prettier-plugin-svelte": "3.2.6",
|
||||||
"svelte": "5.0.0-next.220",
|
"svelte": "5.0.0-next.220",
|
||||||
@@ -1631,6 +1632,16 @@
|
|||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assert-options": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-qSELrEaEz4sGwTs4Qh+swQkjiHAysC4rot21+jzXU86dJzNG+FDqBzyS3ohSoTRf4ZLA3FSwxQdiuNl5NXUtvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -1700,6 +1711,16 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-writer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/builtin-modules": {
|
"node_modules/builtin-modules": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
|
||||||
@@ -1781,6 +1802,100 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1813,6 +1928,20 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/commandpost": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commandpost/-/commandpost-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-aE2Y4MTFJ870NuB/+2z1cXBhSBBzRydVVjzhFC4gtenEhpnj15yu0qptWGJsO9YGrcPZ3ezX8AWb1VA391MKpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/commondir": {
|
"node_modules/commondir": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
@@ -1985,6 +2114,43 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/editorconfig": {
|
||||||
|
"version": "0.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz",
|
||||||
|
"integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^2.19.0",
|
||||||
|
"lru-cache": "^4.1.5",
|
||||||
|
"semver": "^5.6.0",
|
||||||
|
"sigmund": "^1.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"editorconfig": "bin/editorconfig"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/editorconfig/node_modules/lru-cache": {
|
||||||
|
"version": "4.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
|
||||||
|
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"pseudomap": "^1.0.2",
|
||||||
|
"yallist": "^2.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/editorconfig/node_modules/semver": {
|
||||||
|
"version": "5.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||||
|
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
@@ -2050,6 +2216,16 @@
|
|||||||
"@esbuild/win32-x64": "0.21.5"
|
"@esbuild/win32-x64": "0.21.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
@@ -2373,6 +2549,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||||
@@ -2541,11 +2723,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/github-slugger": {
|
"node_modules/get-caller-file": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"license": "ISC"
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
@@ -3077,6 +3263,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -3361,6 +3554,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/packet-reader": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -3440,6 +3640,142 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
|
||||||
|
"integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-writer": "2.0.0",
|
||||||
|
"packet-reader": "1.0.0",
|
||||||
|
"pg-connection-string": "^2.5.0",
|
||||||
|
"pg-pool": "^3.5.2",
|
||||||
|
"pg-protocol": "^1.5.0",
|
||||||
|
"pg-types": "^2.1.0",
|
||||||
|
"pgpass": "1.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz",
|
||||||
|
"integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-minify": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-1KdmFGGTP6jplJoI8MfvRlfvMiyBivMRP7/ffh4a11RUFJ7kC2J0ZHlipoKiH/1hz+DVgceon9U2qbaHpPeyPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-promise": {
|
||||||
|
"version": "10.15.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.15.4.tgz",
|
||||||
|
"integrity": "sha512-BKlHCMCdNUmF6gagVbehRWSEiVcZzPVltEx14OJExR9Iz9/1R6KETDWLLGv2l6yRqYFnEZZy1VDjRhArzeIGrw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-options": "0.8.0",
|
||||||
|
"pg": "8.8.0",
|
||||||
|
"pg-minify": "1.6.2",
|
||||||
|
"spex": "3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-to-ts": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-to-ts/-/pg-to-ts-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-wc/ZXMMQrxu42mnl6eEdMgT31S9rvA/Oh9I9PchovUwoJLzEg0osGQjxiQOLjAdz3Ti45o749XREJ2s+xncZ6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"pg-promise": "^10.11.1",
|
||||||
|
"typescript-formatter": "^7.0.1",
|
||||||
|
"yargs": "^17.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pg-to-ts": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.15.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
|
||||||
@@ -3624,6 +3960,49 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -3661,6 +4040,13 @@
|
|||||||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pseudomap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/psl": {
|
"node_modules/psl": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
||||||
@@ -3716,6 +4102,16 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/requires-port": {
|
"node_modules/requires-port": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
@@ -3974,6 +4370,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sigmund": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -4028,6 +4431,26 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/spex": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/spex/-/spex-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-9srjJM7NaymrpwMHvSmpDeIK5GoRMX/Tq0E8aOlDPS54dDnDUIp30DrP9SphMPEETDLzEM9+4qo+KipmbtPecg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
@@ -4517,6 +4940,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript-formatter": {
|
||||||
|
"version": "7.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript-formatter/-/typescript-formatter-7.2.2.tgz",
|
||||||
|
"integrity": "sha512-V7vfI9XArVhriOTYHPzMU2WUnm5IMdu9X/CPxs8mIMGxmTBFpDABlbkBka64PZJ9/xgQeRpK8KzzAG4MPzxBDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commandpost": "^1.0.0",
|
||||||
|
"editorconfig": "^0.15.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsfmt": "bin/tsfmt"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^2.1.6 || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz",
|
||||||
@@ -4857,6 +5300,33 @@
|
|||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "1.10.2",
|
"version": "1.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||||
@@ -4867,6 +5337,80 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"gents": "pg-to-ts generate -c postgres://postgres@localhost:15432/archtika -o src/lib/db-schema.ts -s internal"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.40.0",
|
"@playwright/test": "1.40.0",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"eslint-config-prettier": "9.1.0",
|
"eslint-config-prettier": "9.1.0",
|
||||||
"eslint-plugin-svelte": "2.43.0",
|
"eslint-plugin-svelte": "2.43.0",
|
||||||
"globals": "15.9.0",
|
"globals": "15.9.0",
|
||||||
|
"pg-to-ts": "4.1.1",
|
||||||
"prettier": "3.3.3",
|
"prettier": "3.3.3",
|
||||||
"prettier-plugin-svelte": "3.2.6",
|
"prettier-plugin-svelte": "3.2.6",
|
||||||
"svelte": "5.0.0-next.220",
|
"svelte": "5.0.0-next.220",
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"github-slugger": "2.0.0",
|
"fast-diff": "1.3.0",
|
||||||
"highlight.js": "11.10.0",
|
"highlight.js": "11.10.0",
|
||||||
"isomorphic-dompurify": "2.14.0",
|
"isomorphic-dompurify": "2.14.0",
|
||||||
"marked": "14.0.0",
|
"marked": "14.0.0",
|
||||||
|
|||||||
5
web-app/src/app.d.ts
vendored
5
web-app/src/app.d.ts
vendored
@@ -1,9 +1,6 @@
|
|||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
interface User {
|
import type { User } from "$lib/db-schema";
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } 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/")) {
|
||||||
@@ -21,7 +22,7 @@ export const handle = async ({ event, resolve }) => {
|
|||||||
throw redirect(303, "/");
|
throw redirect(303, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await userData.json();
|
const user: User = await userData.json();
|
||||||
|
|
||||||
event.locals.user = user;
|
event.locals.user = user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
const { date }: { date: string } = $props();
|
const { date }: { date: Date } = $props();
|
||||||
|
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
const { children, id, text }: { children: Snippet; id: string; text: string } = $props();
|
const {
|
||||||
|
children,
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
isWider = false
|
||||||
|
}: { children: Snippet; id: string; text: string; isWider?: boolean } = $props();
|
||||||
|
|
||||||
const modalId = `${id}-modal`;
|
const modalId = `${id}-modal`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href={`#${modalId}`} role="button">{text}</a>
|
<a href={`#${modalId}`} role="button">{text}</a>
|
||||||
|
|
||||||
<div id={modalId} class="modal">
|
<div id={modalId} class="modal" style="--modal-width: {isWider ? 600 : 300}px">
|
||||||
<div class="modal__content">
|
<div class="modal__content">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
<a href="#!" role="button">Close</a>
|
<a href="#!" role="button">Close</a>
|
||||||
@@ -46,7 +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: 300px;
|
inline-size: var(--modal-width);
|
||||||
max-inline-size: 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;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
const { success, message }: { success: boolean | undefined; message: string | undefined } =
|
const { success, message }: { success?: boolean; message?: string } = $props();
|
||||||
$props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if success}
|
{#if success}
|
||||||
|
|||||||
@@ -49,9 +49,15 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/website/{id}/collaborators">Collaborators</a>
|
<a href="/website/{id}/collaborators">Collaborators</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/website/{id}/legal-information">Legal information</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/website/{id}/publish">Publish</a>
|
<a href="/website/{id}/publish">Publish</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/website/{id}/logs">Logs</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -117,13 +123,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.operations {
|
.operations {
|
||||||
border-inline-end: var(--border-primary);
|
|
||||||
padding-block-start: var(--space-s);
|
padding-block-start: var(--space-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-block-start: var(--space-s);
|
padding-block-start: var(--space-s);
|
||||||
|
border-inline-start: var(--border-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
525
web-app/src/lib/db-schema.ts
Normal file
525
web-app/src/lib/db-schema.ts
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUTO-GENERATED FILE - DO NOT EDIT!
|
||||||
|
*
|
||||||
|
* This file was automatically generated by pg-to-ts v.4.1.1
|
||||||
|
* $ pg-to-ts generate -c postgres://username:password@localhost:15432/archtika -t article -t change_log -t collab -t docs_category -t footer -t header -t home -t legal_information -t media -t settings -t user -t website -s internal
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Json = unknown;
|
||||||
|
|
||||||
|
// Table article
|
||||||
|
export interface Article {
|
||||||
|
id: string;
|
||||||
|
website_id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
title: string;
|
||||||
|
meta_description: string | null;
|
||||||
|
meta_author: string | null;
|
||||||
|
cover_image: string | null;
|
||||||
|
publication_date: Date | null;
|
||||||
|
main_content: string | null;
|
||||||
|
category: string | null;
|
||||||
|
article_weight: number | null;
|
||||||
|
created_at: Date;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
title_description_search: any | null;
|
||||||
|
}
|
||||||
|
export interface ArticleInput {
|
||||||
|
id?: string;
|
||||||
|
website_id: string;
|
||||||
|
user_id?: string | null;
|
||||||
|
title: string;
|
||||||
|
meta_description?: string | null;
|
||||||
|
meta_author?: string | null;
|
||||||
|
cover_image?: string | null;
|
||||||
|
publication_date?: Date | null;
|
||||||
|
main_content?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
article_weight?: number | null;
|
||||||
|
created_at?: Date;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
title_description_search?: any | null;
|
||||||
|
}
|
||||||
|
const article = {
|
||||||
|
tableName: "article",
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"website_id",
|
||||||
|
"user_id",
|
||||||
|
"title",
|
||||||
|
"meta_description",
|
||||||
|
"meta_author",
|
||||||
|
"cover_image",
|
||||||
|
"publication_date",
|
||||||
|
"main_content",
|
||||||
|
"category",
|
||||||
|
"article_weight",
|
||||||
|
"created_at",
|
||||||
|
"last_modified_at",
|
||||||
|
"last_modified_by",
|
||||||
|
"title_description_search"
|
||||||
|
],
|
||||||
|
requiredForInsert: ["website_id", "title"],
|
||||||
|
primaryKey: "id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
user_id: { table: "user", column: "id", $type: null as unknown as User },
|
||||||
|
cover_image: { table: "media", column: "id", $type: null as unknown as Media },
|
||||||
|
category: { table: "docs_category", column: "id", $type: null as unknown as DocsCategory },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as Article,
|
||||||
|
$input: null as unknown as ArticleInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table change_log
|
||||||
|
export interface ChangeLog {
|
||||||
|
id: string;
|
||||||
|
website_id: string | null;
|
||||||
|
user_id: string | null;
|
||||||
|
username: string;
|
||||||
|
tstamp: Date;
|
||||||
|
table_name: string;
|
||||||
|
operation: string;
|
||||||
|
old_value: any | null;
|
||||||
|
new_value: any | null;
|
||||||
|
}
|
||||||
|
export interface ChangeLogInput {
|
||||||
|
id?: string;
|
||||||
|
website_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
username?: string;
|
||||||
|
tstamp?: Date;
|
||||||
|
table_name: string;
|
||||||
|
operation: string;
|
||||||
|
old_value?: any | null;
|
||||||
|
new_value?: any | null;
|
||||||
|
}
|
||||||
|
const change_log = {
|
||||||
|
tableName: "change_log",
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"website_id",
|
||||||
|
"user_id",
|
||||||
|
"username",
|
||||||
|
"tstamp",
|
||||||
|
"table_name",
|
||||||
|
"operation",
|
||||||
|
"old_value",
|
||||||
|
"new_value"
|
||||||
|
],
|
||||||
|
requiredForInsert: ["table_name", "operation"],
|
||||||
|
primaryKey: "id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
user_id: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as ChangeLog,
|
||||||
|
$input: null as unknown as ChangeLogInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table collab
|
||||||
|
export interface Collab {
|
||||||
|
website_id: string;
|
||||||
|
user_id: string;
|
||||||
|
permission_level: number;
|
||||||
|
added_at: Date;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
}
|
||||||
|
export interface CollabInput {
|
||||||
|
website_id: string;
|
||||||
|
user_id: string;
|
||||||
|
permission_level?: number;
|
||||||
|
added_at?: Date;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
}
|
||||||
|
const collab = {
|
||||||
|
tableName: "collab",
|
||||||
|
columns: [
|
||||||
|
"website_id",
|
||||||
|
"user_id",
|
||||||
|
"permission_level",
|
||||||
|
"added_at",
|
||||||
|
"last_modified_at",
|
||||||
|
"last_modified_by"
|
||||||
|
],
|
||||||
|
requiredForInsert: ["website_id", "user_id"],
|
||||||
|
primaryKey: "website_id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
user_id: { table: "user", column: "id", $type: null as unknown as User },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as Collab,
|
||||||
|
$input: null as unknown as CollabInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table docs_category
|
||||||
|
export interface DocsCategory {
|
||||||
|
id: string;
|
||||||
|
website_id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
category_name: string;
|
||||||
|
category_weight: number;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
}
|
||||||
|
export interface DocsCategoryInput {
|
||||||
|
id?: string;
|
||||||
|
website_id: string;
|
||||||
|
user_id?: string | null;
|
||||||
|
category_name: string;
|
||||||
|
category_weight: number;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
}
|
||||||
|
const docs_category = {
|
||||||
|
tableName: "docs_category",
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"website_id",
|
||||||
|
"user_id",
|
||||||
|
"category_name",
|
||||||
|
"category_weight",
|
||||||
|
"last_modified_at",
|
||||||
|
"last_modified_by"
|
||||||
|
],
|
||||||
|
requiredForInsert: ["website_id", "category_name", "category_weight"],
|
||||||
|
primaryKey: "id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
user_id: { table: "user", column: "id", $type: null as unknown as User },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as DocsCategory,
|
||||||
|
$input: null as unknown as DocsCategoryInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table footer
|
||||||
|
export interface Footer {
|
||||||
|
website_id: string;
|
||||||
|
additional_text: string;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
}
|
||||||
|
export interface FooterInput {
|
||||||
|
website_id: string;
|
||||||
|
additional_text: string;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
}
|
||||||
|
const footer = {
|
||||||
|
tableName: "footer",
|
||||||
|
columns: ["website_id", "additional_text", "last_modified_at", "last_modified_by"],
|
||||||
|
requiredForInsert: ["website_id", "additional_text"],
|
||||||
|
primaryKey: "website_id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as Footer,
|
||||||
|
$input: null as unknown as FooterInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
export interface Header {
|
||||||
|
website_id: string;
|
||||||
|
logo_type: string;
|
||||||
|
logo_text: string | null;
|
||||||
|
logo_image: string | null;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
}
|
||||||
|
export interface HeaderInput {
|
||||||
|
website_id: string;
|
||||||
|
logo_type?: string;
|
||||||
|
logo_text?: string | null;
|
||||||
|
logo_image?: string | null;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
}
|
||||||
|
const header = {
|
||||||
|
tableName: "header",
|
||||||
|
columns: [
|
||||||
|
"website_id",
|
||||||
|
"logo_type",
|
||||||
|
"logo_text",
|
||||||
|
"logo_image",
|
||||||
|
"last_modified_at",
|
||||||
|
"last_modified_by"
|
||||||
|
],
|
||||||
|
requiredForInsert: ["website_id"],
|
||||||
|
primaryKey: "website_id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
logo_image: { table: "media", column: "id", $type: null as unknown as Media },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as Header,
|
||||||
|
$input: null as unknown as HeaderInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table home
|
||||||
|
export interface Home {
|
||||||
|
website_id: string;
|
||||||
|
main_content: string;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
}
|
||||||
|
export interface HomeInput {
|
||||||
|
website_id: string;
|
||||||
|
main_content: string;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
}
|
||||||
|
const home = {
|
||||||
|
tableName: "home",
|
||||||
|
columns: ["website_id", "main_content", "last_modified_at", "last_modified_by"],
|
||||||
|
requiredForInsert: ["website_id", "main_content"],
|
||||||
|
primaryKey: "website_id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as Home,
|
||||||
|
$input: null as unknown as HomeInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table legal_information
|
||||||
|
export interface LegalInformation {
|
||||||
|
website_id: string;
|
||||||
|
main_content: string;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
}
|
||||||
|
export interface LegalInformationInput {
|
||||||
|
website_id: string;
|
||||||
|
main_content: string;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
}
|
||||||
|
const legal_information = {
|
||||||
|
tableName: "legal_information",
|
||||||
|
columns: ["website_id", "main_content", "last_modified_at", "last_modified_by"],
|
||||||
|
requiredForInsert: ["website_id", "main_content"],
|
||||||
|
primaryKey: "website_id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as LegalInformation,
|
||||||
|
$input: null as unknown as LegalInformationInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table media
|
||||||
|
export interface Media {
|
||||||
|
id: string;
|
||||||
|
website_id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
blob: string;
|
||||||
|
mimetype: string;
|
||||||
|
original_name: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
export interface MediaInput {
|
||||||
|
id?: string;
|
||||||
|
website_id: string;
|
||||||
|
user_id?: string | null;
|
||||||
|
blob: string;
|
||||||
|
mimetype: string;
|
||||||
|
original_name: string;
|
||||||
|
created_at?: Date;
|
||||||
|
}
|
||||||
|
const media = {
|
||||||
|
tableName: "media",
|
||||||
|
columns: ["id", "website_id", "user_id", "blob", "mimetype", "original_name", "created_at"],
|
||||||
|
requiredForInsert: ["website_id", "blob", "mimetype", "original_name"],
|
||||||
|
primaryKey: "id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
user_id: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as Media,
|
||||||
|
$input: null as unknown as MediaInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table settings
|
||||||
|
export interface Settings {
|
||||||
|
website_id: string;
|
||||||
|
accent_color_light_theme: string;
|
||||||
|
accent_color_dark_theme: string;
|
||||||
|
favicon_image: string | null;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
}
|
||||||
|
export interface SettingsInput {
|
||||||
|
website_id: string;
|
||||||
|
accent_color_light_theme?: string;
|
||||||
|
accent_color_dark_theme?: string;
|
||||||
|
favicon_image?: string | null;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
}
|
||||||
|
const settings = {
|
||||||
|
tableName: "settings",
|
||||||
|
columns: [
|
||||||
|
"website_id",
|
||||||
|
"accent_color_light_theme",
|
||||||
|
"accent_color_dark_theme",
|
||||||
|
"favicon_image",
|
||||||
|
"last_modified_at",
|
||||||
|
"last_modified_by"
|
||||||
|
],
|
||||||
|
requiredForInsert: ["website_id"],
|
||||||
|
primaryKey: "website_id",
|
||||||
|
foreignKeys: {
|
||||||
|
website_id: { table: "website", column: "id", $type: null as unknown as Website },
|
||||||
|
favicon_image: { table: "media", column: "id", $type: null as unknown as Media },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as Settings,
|
||||||
|
$input: null as unknown as SettingsInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table user
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
export interface UserInput {
|
||||||
|
id?: string;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
const user = {
|
||||||
|
tableName: "user",
|
||||||
|
columns: ["id", "username", "password_hash", "role"],
|
||||||
|
requiredForInsert: ["username", "password_hash"],
|
||||||
|
primaryKey: "id",
|
||||||
|
foreignKeys: {},
|
||||||
|
$type: null as unknown as User,
|
||||||
|
$input: null as unknown as UserInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Table website
|
||||||
|
export interface Website {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
content_type: string;
|
||||||
|
title: string;
|
||||||
|
created_at: Date;
|
||||||
|
is_published: boolean;
|
||||||
|
last_modified_at: Date;
|
||||||
|
last_modified_by: string | null;
|
||||||
|
title_search: any | null;
|
||||||
|
}
|
||||||
|
export interface WebsiteInput {
|
||||||
|
id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
content_type: string;
|
||||||
|
title: string;
|
||||||
|
created_at?: Date;
|
||||||
|
is_published?: boolean;
|
||||||
|
last_modified_at?: Date;
|
||||||
|
last_modified_by?: string | null;
|
||||||
|
title_search?: any | null;
|
||||||
|
}
|
||||||
|
const website = {
|
||||||
|
tableName: "website",
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"content_type",
|
||||||
|
"title",
|
||||||
|
"created_at",
|
||||||
|
"is_published",
|
||||||
|
"last_modified_at",
|
||||||
|
"last_modified_by",
|
||||||
|
"title_search"
|
||||||
|
],
|
||||||
|
requiredForInsert: ["content_type", "title"],
|
||||||
|
primaryKey: "id",
|
||||||
|
foreignKeys: {
|
||||||
|
user_id: { table: "user", column: "id", $type: null as unknown as User },
|
||||||
|
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
|
||||||
|
},
|
||||||
|
$type: null as unknown as Website,
|
||||||
|
$input: null as unknown as WebsiteInput
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface TableTypes {
|
||||||
|
article: {
|
||||||
|
select: Article;
|
||||||
|
input: ArticleInput;
|
||||||
|
};
|
||||||
|
change_log: {
|
||||||
|
select: ChangeLog;
|
||||||
|
input: ChangeLogInput;
|
||||||
|
};
|
||||||
|
collab: {
|
||||||
|
select: Collab;
|
||||||
|
input: CollabInput;
|
||||||
|
};
|
||||||
|
docs_category: {
|
||||||
|
select: DocsCategory;
|
||||||
|
input: DocsCategoryInput;
|
||||||
|
};
|
||||||
|
footer: {
|
||||||
|
select: Footer;
|
||||||
|
input: FooterInput;
|
||||||
|
};
|
||||||
|
header: {
|
||||||
|
select: Header;
|
||||||
|
input: HeaderInput;
|
||||||
|
};
|
||||||
|
home: {
|
||||||
|
select: Home;
|
||||||
|
input: HomeInput;
|
||||||
|
};
|
||||||
|
legal_information: {
|
||||||
|
select: LegalInformation;
|
||||||
|
input: LegalInformationInput;
|
||||||
|
};
|
||||||
|
media: {
|
||||||
|
select: Media;
|
||||||
|
input: MediaInput;
|
||||||
|
};
|
||||||
|
settings: {
|
||||||
|
select: Settings;
|
||||||
|
input: SettingsInput;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
select: User;
|
||||||
|
input: UserInput;
|
||||||
|
};
|
||||||
|
website: {
|
||||||
|
select: Website;
|
||||||
|
input: WebsiteInput;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tables = {
|
||||||
|
article,
|
||||||
|
change_log,
|
||||||
|
collab,
|
||||||
|
docs_category,
|
||||||
|
footer,
|
||||||
|
header,
|
||||||
|
home,
|
||||||
|
legal_information,
|
||||||
|
media,
|
||||||
|
settings,
|
||||||
|
user,
|
||||||
|
website
|
||||||
|
};
|
||||||
@@ -2,52 +2,46 @@
|
|||||||
import Head from "../common/Head.svelte";
|
import Head from "../common/Head.svelte";
|
||||||
import Nav from "../common/Nav.svelte";
|
import Nav from "../common/Nav.svelte";
|
||||||
import Footer from "../common/Footer.svelte";
|
import Footer from "../common/Footer.svelte";
|
||||||
|
import { type WebsiteOverview, md } from "../../utils";
|
||||||
|
import type { Article } from "../../db-schema";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
favicon,
|
websiteOverview,
|
||||||
title,
|
article,
|
||||||
logoType,
|
apiUrl
|
||||||
logo,
|
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string } = $props();
|
||||||
mainContent,
|
|
||||||
coverImage,
|
|
||||||
publicationDate,
|
|
||||||
footerAdditionalText,
|
|
||||||
metaDescription
|
|
||||||
}: {
|
|
||||||
favicon: string;
|
|
||||||
title: string;
|
|
||||||
logoType: "text" | "image";
|
|
||||||
logo: string;
|
|
||||||
mainContent: string;
|
|
||||||
coverImage: string;
|
|
||||||
publicationDate: string;
|
|
||||||
footerAdditionalText: string;
|
|
||||||
metaDescription: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Head {title} {favicon} nestingLevel={1} {metaDescription} />
|
<Head
|
||||||
|
{websiteOverview}
|
||||||
|
nestingLevel={1}
|
||||||
|
{apiUrl}
|
||||||
|
title={article.title}
|
||||||
|
metaDescription={article.meta_description}
|
||||||
|
/>
|
||||||
|
|
||||||
<Nav {logoType} {logo} isIndexPage={false} />
|
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={false} {apiUrl} />
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<hgroup>
|
<hgroup>
|
||||||
<p>{publicationDate}</p>
|
{#if article.publication_date}
|
||||||
<h1>{title}</h1>
|
<p>{article.publication_date}</p>
|
||||||
|
{/if}
|
||||||
|
<h1>{article.title}</h1>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
{#if coverImage}
|
{#if article.cover_image}
|
||||||
<img src={coverImage} alt="" />
|
<img src="{apiUrl}/rpc/retrieve_file?id={article.cover_image}" alt="" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if mainContent}
|
{#if article.main_content}
|
||||||
<main>
|
<main>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{@html mainContent}
|
{@html md(article.main_content)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Footer text={footerAdditionalText} />
|
<Footer {websiteOverview} isIndexPage={false} />
|
||||||
|
|||||||
@@ -2,53 +2,53 @@
|
|||||||
import Head from "../common/Head.svelte";
|
import Head from "../common/Head.svelte";
|
||||||
import Nav from "../common/Nav.svelte";
|
import Nav from "../common/Nav.svelte";
|
||||||
import Footer from "../common/Footer.svelte";
|
import Footer from "../common/Footer.svelte";
|
||||||
|
import { md, slugify, type WebsiteOverview } from "$lib/utils";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
favicon,
|
websiteOverview,
|
||||||
title,
|
apiUrl,
|
||||||
logoType,
|
isLegalPage
|
||||||
logo,
|
}: { websiteOverview: WebsiteOverview; apiUrl: string; isLegalPage: boolean } = $props();
|
||||||
mainContent,
|
|
||||||
articles,
|
|
||||||
footerAdditionalText
|
|
||||||
}: {
|
|
||||||
favicon: string;
|
|
||||||
title: string;
|
|
||||||
logoType: "text" | "image";
|
|
||||||
logo: string;
|
|
||||||
mainContent: string;
|
|
||||||
articles: { title: string; publication_date: string; meta_description: string }[];
|
|
||||||
footerAdditionalText: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Head {title} {favicon} />
|
<Head
|
||||||
|
{websiteOverview}
|
||||||
|
nestingLevel={0}
|
||||||
|
{apiUrl}
|
||||||
|
title={isLegalPage ? "Legal information" : websiteOverview.title}
|
||||||
|
/>
|
||||||
|
|
||||||
<Nav {logoType} {logo} />
|
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={true} {apiUrl} />
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{title}</h1>
|
<h1>{isLegalPage ? "Legal information" : websiteOverview.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{@html mainContent}
|
{@html md(
|
||||||
{#if articles.length > 0}
|
isLegalPage
|
||||||
|
? (websiteOverview.legal_information?.main_content ?? "")
|
||||||
|
: websiteOverview.home.main_content,
|
||||||
|
false
|
||||||
|
)}
|
||||||
|
{#if websiteOverview.article.length > 0 && !isLegalPage}
|
||||||
<section class="articles" id="articles">
|
<section class="articles" id="articles">
|
||||||
<h2>
|
<h2>
|
||||||
<a href="#articles">Articles</a>
|
<a href="#articles">Articles</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul class="unpadded">
|
<ul class="unpadded">
|
||||||
{#each articles as article}
|
{#each websiteOverview.article as article}
|
||||||
{@const articleFileName = article.title.toLowerCase().split(" ").join("-")}
|
|
||||||
<li>
|
<li>
|
||||||
<p>{article.publication_date}</p>
|
{#if article.publication_date}
|
||||||
|
<p>{article.publication_date}</p>
|
||||||
|
{/if}
|
||||||
<p>
|
<p>
|
||||||
<strong>
|
<strong>
|
||||||
<a href="./articles/{articleFileName}">{article.title}</a>
|
<a href="./articles/{slugify(article.title)}">{article.title}</a>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
{#if article.meta_description}
|
{#if article.meta_description}
|
||||||
@@ -62,4 +62,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer text={footerAdditionalText} />
|
<Footer {websiteOverview} isIndexPage={true} />
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
const { text }: { text: string } = $props();
|
import type { WebsiteOverview } from "../../utils";
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteOverview,
|
||||||
|
isIndexPage
|
||||||
|
}: { websiteOverview: WebsiteOverview; isIndexPage: boolean } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<small>
|
<small>
|
||||||
{@html text}
|
{@html websiteOverview.footer.additional_text.replace(
|
||||||
|
"!!legal",
|
||||||
|
`<a href="${isIndexPage ? "./legal-information" : "../legal-information"}">Legal information</a>`
|
||||||
|
)}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { WebsiteOverview } from "../../utils";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
websiteOverview,
|
||||||
|
nestingLevel,
|
||||||
|
apiUrl,
|
||||||
title,
|
title,
|
||||||
favicon,
|
metaDescription
|
||||||
nestingLevel = 0,
|
|
||||||
metaDescription = null
|
|
||||||
}: {
|
}: {
|
||||||
|
websiteOverview: WebsiteOverview;
|
||||||
|
nestingLevel: number;
|
||||||
|
apiUrl: string;
|
||||||
title: string;
|
title: string;
|
||||||
favicon: string;
|
|
||||||
nestingLevel?: number;
|
|
||||||
metaDescription?: string | null;
|
metaDescription?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -19,8 +23,11 @@
|
|||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta name="description" content={metaDescription ?? title} />
|
<meta name="description" content={metaDescription ?? title} />
|
||||||
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}styles.css`} />
|
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}styles.css`} />
|
||||||
{#if favicon}
|
{#if websiteOverview.settings.favicon_image}
|
||||||
<link rel="icon" href={favicon} />
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="{apiUrl}/rpc/retrieve_file?id={websiteOverview.settings.favicon_image}"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</head>
|
</head>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { type WebsiteOverview, slugify } from "../../utils";
|
||||||
|
import type { Article } from "../../db-schema";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
logoType,
|
websiteOverview,
|
||||||
logo,
|
isDocsTemplate,
|
||||||
isDocsTemplate = false,
|
isIndexPage,
|
||||||
categorizedArticles = {},
|
apiUrl
|
||||||
isIndexPage = true
|
|
||||||
}: {
|
}: {
|
||||||
logoType: "text" | "image";
|
websiteOverview: WebsiteOverview;
|
||||||
logo: string;
|
isDocsTemplate: boolean;
|
||||||
isDocsTemplate?: boolean;
|
isIndexPage: boolean;
|
||||||
categorizedArticles?: { [key: string]: { title: string }[] };
|
apiUrl: string;
|
||||||
isIndexPage?: boolean;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const categorizedArticles = Object.fromEntries(
|
||||||
|
Object.entries(
|
||||||
|
Object.groupBy(
|
||||||
|
websiteOverview.article.sort((a, b) => (b.article_weight ?? 0) - (a.article_weight ?? 0)),
|
||||||
|
(article) => article.docs_category?.category_name ?? "Uncategorized"
|
||||||
|
)
|
||||||
|
).sort(([a], [b]) =>
|
||||||
|
a === "Uncategorized"
|
||||||
|
? 1
|
||||||
|
: b === "Uncategorized"
|
||||||
|
? -1
|
||||||
|
: (websiteOverview.article.find((art) => art.docs_category?.category_name === b)
|
||||||
|
?.docs_category?.category_weight ?? 0) -
|
||||||
|
(websiteOverview.article.find((art) => art.docs_category?.category_name === a)
|
||||||
|
?.docs_category?.category_weight ?? 0)
|
||||||
|
)
|
||||||
|
) as { [key: string]: Article[] };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
@@ -41,9 +60,8 @@
|
|||||||
<strong>{key}</strong>
|
<strong>{key}</strong>
|
||||||
<ul>
|
<ul>
|
||||||
{#each categorizedArticles[key] as { title }}
|
{#each categorizedArticles[key] as { title }}
|
||||||
{@const articleFileName = title.toLowerCase().split(" ").join("-")}
|
|
||||||
<li>
|
<li>
|
||||||
<a href="{isIndexPage ? './articles' : '.'}/{articleFileName}">{title}</a>
|
<a href="{isIndexPage ? './articles' : '.'}/{slugify(title)}">{title}</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -53,10 +71,15 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
<a href={isIndexPage ? "." : ".."}>
|
<a href={isIndexPage ? "." : ".."}>
|
||||||
{#if logoType === "text"}
|
{#if websiteOverview.header.logo_type === "text"}
|
||||||
<strong>{logo}</strong>
|
<strong>{websiteOverview.header.logo_text}</strong>
|
||||||
{:else}
|
{:else}
|
||||||
<img src={logo} width="24" height="24" alt="" />
|
<img
|
||||||
|
src="{apiUrl}/rpc/retrieve_file?id={websiteOverview.header.logo_image}"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,44 +2,38 @@
|
|||||||
import Head from "../common/Head.svelte";
|
import Head from "../common/Head.svelte";
|
||||||
import Nav from "../common/Nav.svelte";
|
import Nav from "../common/Nav.svelte";
|
||||||
import Footer from "../common/Footer.svelte";
|
import Footer from "../common/Footer.svelte";
|
||||||
|
import { md, type WebsiteOverview } from "../../utils";
|
||||||
|
import type { Article } from "../../db-schema";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
favicon,
|
websiteOverview,
|
||||||
title,
|
article,
|
||||||
logoType,
|
apiUrl
|
||||||
logo,
|
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string } = $props();
|
||||||
mainContent,
|
|
||||||
categorizedArticles,
|
|
||||||
footerAdditionalText,
|
|
||||||
metaDescription
|
|
||||||
}: {
|
|
||||||
favicon: string;
|
|
||||||
title: string;
|
|
||||||
logoType: "text" | "image";
|
|
||||||
logo: string;
|
|
||||||
mainContent: string;
|
|
||||||
categorizedArticles: { [key: string]: { title: string }[] };
|
|
||||||
footerAdditionalText: string;
|
|
||||||
metaDescription: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Head {title} {favicon} nestingLevel={1} {metaDescription} />
|
<Head
|
||||||
|
{websiteOverview}
|
||||||
|
nestingLevel={1}
|
||||||
|
{apiUrl}
|
||||||
|
title={article.title}
|
||||||
|
metaDescription={article.meta_description}
|
||||||
|
/>
|
||||||
|
|
||||||
<Nav {logoType} {logo} isDocsTemplate={true} {categorizedArticles} isIndexPage={false} />
|
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={false} {apiUrl} />
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{title}</h1>
|
<h1>{article.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if mainContent}
|
{#if article.main_content}
|
||||||
<main>
|
<main>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{@html mainContent}
|
{@html md(article.main_content)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Footer text={footerAdditionalText} />
|
<Footer {websiteOverview} isIndexPage={false} />
|
||||||
|
|||||||
@@ -2,40 +2,39 @@
|
|||||||
import Head from "../common/Head.svelte";
|
import Head from "../common/Head.svelte";
|
||||||
import Nav from "../common/Nav.svelte";
|
import Nav from "../common/Nav.svelte";
|
||||||
import Footer from "../common/Footer.svelte";
|
import Footer from "../common/Footer.svelte";
|
||||||
|
import { md, type WebsiteOverview } from "../../utils";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
favicon,
|
websiteOverview,
|
||||||
title,
|
apiUrl,
|
||||||
logoType,
|
isLegalPage
|
||||||
logo,
|
}: { websiteOverview: WebsiteOverview; apiUrl: string; isLegalPage: boolean } = $props();
|
||||||
mainContent,
|
|
||||||
categorizedArticles,
|
|
||||||
footerAdditionalText
|
|
||||||
}: {
|
|
||||||
favicon: string;
|
|
||||||
title: string;
|
|
||||||
logoType: "text" | "image";
|
|
||||||
logo: string;
|
|
||||||
mainContent: string;
|
|
||||||
categorizedArticles: { [key: string]: { title: string }[] };
|
|
||||||
footerAdditionalText: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Head {title} {favicon} />
|
<Head
|
||||||
|
{websiteOverview}
|
||||||
|
nestingLevel={0}
|
||||||
|
{apiUrl}
|
||||||
|
title={isLegalPage ? "Legal information" : websiteOverview.title}
|
||||||
|
/>
|
||||||
|
|
||||||
<Nav {logoType} {logo} isDocsTemplate={true} {categorizedArticles} />
|
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={true} {apiUrl} />
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{title}</h1>
|
<h1>{isLegalPage ? "Legal information" : websiteOverview.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{@html mainContent}
|
{@html md(
|
||||||
|
isLegalPage
|
||||||
|
? (websiteOverview.legal_information?.main_content ?? "")
|
||||||
|
: websiteOverview.home.main_content,
|
||||||
|
false
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer text={footerAdditionalText} />
|
<Footer {websiteOverview} isIndexPage={true} />
|
||||||
|
|||||||
@@ -2,11 +2,33 @@ import { Marked } from "marked";
|
|||||||
import type { Renderer, Token } from "marked";
|
import type { Renderer, Token } from "marked";
|
||||||
import { markedHighlight } from "marked-highlight";
|
import { markedHighlight } from "marked-highlight";
|
||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import GithubSlugger from "github-slugger";
|
|
||||||
import DOMPurify from "isomorphic-dompurify";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
import { applyAction, deserialize } from "$app/forms";
|
import { applyAction, deserialize } from "$app/forms";
|
||||||
|
import type {
|
||||||
|
Website,
|
||||||
|
Settings,
|
||||||
|
Header,
|
||||||
|
Home,
|
||||||
|
Footer,
|
||||||
|
Article,
|
||||||
|
DocsCategory,
|
||||||
|
LegalInformation
|
||||||
|
} from "$lib/db-schema";
|
||||||
|
|
||||||
export const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/svg+xml", "image/webp"];
|
export const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
|
||||||
|
export const slugify = (string: string) => {
|
||||||
|
return string
|
||||||
|
.toString()
|
||||||
|
.normalize("NFKD") // Normalize Unicode characters
|
||||||
|
.toLowerCase() // Convert to lowercase
|
||||||
|
.trim() // Trim leading and trailing whitespace
|
||||||
|
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||||
|
.replace(/[^\w\-]+/g, "") // Remove non-word characters (except hyphens)
|
||||||
|
.replace(/\-\-+/g, "-") // Replace multiple hyphens with single hyphen
|
||||||
|
.replace(/^-+/, "") // Remove leading hyphens
|
||||||
|
.replace(/-+$/, ""); // Remove trailing hyphens
|
||||||
|
};
|
||||||
|
|
||||||
const createMarkdownParser = (showToc = true) => {
|
const createMarkdownParser = (showToc = true) => {
|
||||||
const marked = new Marked();
|
const marked = new Marked();
|
||||||
@@ -28,36 +50,17 @@ const createMarkdownParser = (showToc = true) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;
|
|
||||||
|
|
||||||
const unescape = (html: string) => {
|
|
||||||
return html.replace(unescapeTest, (_, n) => {
|
|
||||||
n = n.toLowerCase();
|
|
||||||
if (n === "colon") return ":";
|
|
||||||
if (n.charAt(0) === "#") {
|
|
||||||
return n.charAt(1) === "x"
|
|
||||||
? String.fromCharCode(parseInt(n.substring(2), 16))
|
|
||||||
: String.fromCharCode(+n.substring(1));
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let slugger = new GithubSlugger();
|
|
||||||
let headings: { text: string; raw: string; level: number; id: string }[] = [];
|
|
||||||
let sectionStack: { level: number; id: string }[] = [];
|
|
||||||
|
|
||||||
const gfmHeadingId = ({ prefix = "", showToc = true } = {}) => {
|
const gfmHeadingId = ({ prefix = "", showToc = true } = {}) => {
|
||||||
|
let headings: { text: string; level: number; id: string }[] = [];
|
||||||
|
let sectionStack: { level: number; id: string }[] = [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
renderer: {
|
renderer: {
|
||||||
heading(this: Renderer, { tokens, depth }: { tokens: Token[]; depth: number }) {
|
heading(this: Renderer, { tokens, depth }: { tokens: Token[]; depth: number }) {
|
||||||
const text = this.parser.parseInline(tokens);
|
const text = this.parser.parseInline(tokens);
|
||||||
const raw = unescape(this.parser.parseInline(tokens, this.parser.textRenderer))
|
|
||||||
.trim()
|
|
||||||
.replace(/<[!a-z].*?>/gi, "");
|
|
||||||
const level = depth;
|
const level = depth;
|
||||||
const id = `${prefix}${slugger.slug(raw.toLowerCase())}`;
|
const id = `${prefix}${slugify(text)}`;
|
||||||
const heading = { level, text, id, raw };
|
const heading = { level, text, id };
|
||||||
headings.push(heading);
|
headings.push(heading);
|
||||||
|
|
||||||
let closingSections = "";
|
let closingSections = "";
|
||||||
@@ -79,16 +82,7 @@ const createMarkdownParser = (showToc = true) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
preprocess(src: string) {
|
|
||||||
headings = [];
|
|
||||||
sectionStack = [];
|
|
||||||
slugger = new GithubSlugger();
|
|
||||||
|
|
||||||
return src;
|
|
||||||
},
|
|
||||||
postprocess(html: string) {
|
postprocess(html: string) {
|
||||||
const closingRemainingSection = "</section>".repeat(sectionStack.length);
|
|
||||||
|
|
||||||
let tableOfContents = "";
|
let tableOfContents = "";
|
||||||
if (showToc && headings.length > 0) {
|
if (showToc && headings.length > 0) {
|
||||||
const tocItems = [];
|
const tocItems = [];
|
||||||
@@ -130,7 +124,7 @@ const createMarkdownParser = (showToc = true) => {
|
|||||||
return `
|
return `
|
||||||
${tableOfContents}
|
${tableOfContents}
|
||||||
${html}
|
${html}
|
||||||
${closingRemainingSection}
|
${"</section>".repeat(sectionStack.length)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +144,7 @@ export const md = (markdownContent: string, showToc = true) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleImagePaste = async (event: ClipboardEvent, API_BASE_PREFIX: string) => {
|
export const handleImagePaste = async (event: ClipboardEvent, API_BASE_PREFIX: string) => {
|
||||||
const clipboardItems = Array.from(event.clipboardData?.items || []);
|
const clipboardItems = Array.from(event.clipboardData?.items ?? []);
|
||||||
const file = clipboardItems.find((item) => item.type.startsWith("image/"));
|
const file = clipboardItems.find((item) => item.type.startsWith("image/"));
|
||||||
|
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
@@ -189,3 +183,12 @@ export const handleImagePaste = async (event: ClipboardEvent, API_BASE_PREFIX: s
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface WebsiteOverview extends Website {
|
||||||
|
settings: Settings;
|
||||||
|
header: Header;
|
||||||
|
home: Home;
|
||||||
|
footer: Footer;
|
||||||
|
article: (Article & { docs_category: DocsCategory | null })[];
|
||||||
|
legal_information?: LegalInformation;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const actions: Actions = {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: data.get("username"),
|
username: data.get("username"),
|
||||||
password: data.get("password")
|
pass: data.get("password")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const actions: Actions = {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: data.get("username"),
|
username: data.get("username"),
|
||||||
password: data.get("password")
|
pass: data.get("password")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Actions, PageServerLoad } from "./$types";
|
|||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { Website, WebsiteInput } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => {
|
||||||
const searchQuery = url.searchParams.get("website_search_query");
|
const searchQuery = url.searchParams.get("website_search_query");
|
||||||
@@ -47,7 +48,7 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const websites = await websiteData.json();
|
const websites: Website[] = await websiteData.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalWebsiteCount,
|
totalWebsiteCount,
|
||||||
@@ -66,9 +67,9 @@ export const actions: Actions = {
|
|||||||
Authorization: `Bearer ${cookies.get("session_token")}`
|
Authorization: `Bearer ${cookies.get("session_token")}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content_type: data.get("content-type"),
|
content_type: data.get("content-type") as string,
|
||||||
title: data.get("title")
|
title: data.get("title") as string
|
||||||
})
|
} satisfies WebsiteInput)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const actions: Actions = {
|
|||||||
Authorization: `Bearer ${cookies.get("session_token")}`
|
Authorization: `Bearer ${cookies.get("session_token")}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
password: data.get("password")
|
pass: data.get("password")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import type { LayoutServerLoad } from "./$types";
|
import type { LayoutServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
|
import type { Website, Home, User } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => {
|
export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => {
|
||||||
const websiteData = await fetch(`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`, {
|
const websiteData = await fetch(
|
||||||
method: "GET",
|
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,user!user_id(username)`,
|
||||||
headers: {
|
{
|
||||||
"Content-Type": "application/json",
|
method: "GET",
|
||||||
Authorization: `Bearer ${cookies.get("session_token")}`,
|
headers: {
|
||||||
Accept: "application/vnd.pgrst.object+json"
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${cookies.get("session_token")}`,
|
||||||
|
Accept: "application/vnd.pgrst.object+json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!websiteData.ok) {
|
if (!websiteData.ok) {
|
||||||
throw error(404, "Website not found");
|
throw error(404, "Website not found");
|
||||||
@@ -25,8 +29,8 @@ export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const website = await websiteData.json();
|
const website: Website & { user: { username: User["username"] } } = await websiteData.json();
|
||||||
const home = await homeData.json();
|
const home: Home = await homeData.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
website,
|
website,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
|
import type { Settings, Header, Footer } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
|
export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
|
||||||
const globalSettingsData = await fetch(
|
const globalSettingsData = await fetch(
|
||||||
@@ -32,9 +33,9 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalSettings = await globalSettingsData.json();
|
const globalSettings: Settings = await globalSettingsData.json();
|
||||||
const header = await headerData.json();
|
const header: Header = await headerData.json();
|
||||||
const footer = await footerData.json();
|
const footer: Footer = await footerData.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
globalSettings,
|
globalSettings,
|
||||||
|
|||||||
@@ -39,7 +39,8 @@
|
|||||||
id={data.website.id}
|
id={data.website.id}
|
||||||
contentType={data.website.content_type}
|
contentType={data.website.content_type}
|
||||||
title={data.website.title}
|
title={data.website.title}
|
||||||
{previewContent}
|
previewContent={previewContent ||
|
||||||
|
"Put some markdown content in main content to see a live preview here"}
|
||||||
previewScrollTop={textareaScrollTop}
|
previewScrollTop={textareaScrollTop}
|
||||||
>
|
>
|
||||||
<section id="global">
|
<section id="global">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
|
import type { Article, ArticleInput, DocsCategory } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent, locals }) => {
|
export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent, locals }) => {
|
||||||
const searchQuery = url.searchParams.get("article_search_query");
|
const searchQuery = url.searchParams.get("article_search_query");
|
||||||
@@ -54,7 +55,7 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const articles = await articlesData.json();
|
const articles: (Article & { docs_category: DocsCategory | null })[] = await articlesData.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalArticleCount,
|
totalArticleCount,
|
||||||
@@ -76,8 +77,8 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
website_id: params.websiteId,
|
website_id: params.websiteId,
|
||||||
title: data.get("title")
|
title: data.get("title") as string
|
||||||
})
|
} satisfies ArticleInput)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
|
import type { Article, DocsCategory } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
|
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
|
||||||
const articleData = await fetch(`${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, {
|
const articleData = await fetch(`${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, {
|
||||||
@@ -22,8 +23,8 @@ export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) =
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const article = await articleData.json();
|
const article: Article = await articleData.json();
|
||||||
const categories = await categoryData.json();
|
const categories: DocsCategory[] = await categoryData.json();
|
||||||
const { website } = await parent();
|
const { website } = await parent();
|
||||||
|
|
||||||
return { website, article, categories, API_BASE_PREFIX };
|
return { website, article, categories, API_BASE_PREFIX };
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
name="publication-date"
|
name="publication-date"
|
||||||
value={data.article.publication_date}
|
value={data.article.publication_date ?? new Date().toISOString().split("T")[0]}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
|
import type { DocsCategory, DocsCategoryInput } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
|
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
|
||||||
const categoryData = await fetch(
|
const categoryData = await fetch(
|
||||||
@@ -13,7 +14,7 @@ export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) =
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const categories = await categoryData.json();
|
const categories: DocsCategory[] = await categoryData.json();
|
||||||
const { website, home } = await parent();
|
const { website, home } = await parent();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -35,9 +36,9 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
website_id: params.websiteId,
|
website_id: params.websiteId,
|
||||||
category_name: data.get("category-name"),
|
category_name: data.get("category-name") as string,
|
||||||
category_weight: data.get("category-weight")
|
category_weight: data.get("category-weight") as unknown as number
|
||||||
})
|
} satisfies DocsCategoryInput)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
|
import type { Collab, CollabInput, User } from "$lib/db-schema";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) => {
|
export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) => {
|
||||||
const { website, home } = await parent();
|
const { website, home } = await parent();
|
||||||
@@ -15,7 +16,7 @@ export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) =
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const collaborators = await collabData.json();
|
const collaborators: (Collab & { user: User })[] = await collabData.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
website,
|
website,
|
||||||
@@ -37,6 +38,8 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const user: User = await userData.json();
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_PREFIX}/collab`, {
|
const res = await fetch(`${API_BASE_PREFIX}/collab`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -45,9 +48,9 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
website_id: params.websiteId,
|
website_id: params.websiteId,
|
||||||
user_id: (await userData.json()).id,
|
user_id: user.id,
|
||||||
permission_level: data.get("permission-level")
|
permission_level: data.get("permission-level") as unknown as number
|
||||||
})
|
} satisfies CollabInput)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -69,10 +69,10 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul class="unpadded">
|
<ul class="unpadded">
|
||||||
{#each data.collaborators as { website_id, user_id, permission_level, user: { username } } (`${website_id}-${user_id}`)}
|
{#each data.collaborators as { website_id, user_id, permission_level, user } (`${website_id}-${user_id}`)}
|
||||||
<li class="collaborator-card">
|
<li class="collaborator-card">
|
||||||
<p>
|
<p>
|
||||||
<strong>{username} ({permission_level})</strong>
|
<strong>{user?.username} ({permission_level})</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="collaborator-card__actions">
|
<div class="collaborator-card__actions">
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { LegalInformation, LegalInformationInput } from "$lib/db-schema";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ parent, fetch, params, cookies }) => {
|
||||||
|
const legalInformationData = await fetch(
|
||||||
|
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${cookies.get("session_token")}`,
|
||||||
|
Accept: "application/vnd.pgrst.object+json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const legalInformation: LegalInformation = await legalInformationData.json();
|
||||||
|
const { website } = await parent();
|
||||||
|
|
||||||
|
return {
|
||||||
|
legalInformation,
|
||||||
|
website
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
createUpdateLegalInformation: async ({ request, fetch, cookies, params }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_PREFIX}/legal_information`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${cookies.get("session_token")}`,
|
||||||
|
Prefer: "resolution=merge-duplicates",
|
||||||
|
Accept: "application/vnd.pgrst.object+json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
website_id: params.websiteId,
|
||||||
|
main_content: data.get("main-content") as string
|
||||||
|
} satisfies LegalInformationInput)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const response = await res.json();
|
||||||
|
return { success: false, message: response.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully ${res.status === 201 ? "created" : "updated"} legal information`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
deleteLegalInformation: async ({ fetch, cookies, params }) => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${cookies.get("session_token")}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const response = await res.json();
|
||||||
|
return { success: false, message: response.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(
|
||||||
|
join("/", "var", "www", "archtika-websites", params.websiteId, "legal-information.html"),
|
||||||
|
{ force: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, message: `Successfully deleted legal information` };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import WebsiteEditor from "$lib/components/WebsiteEditor.svelte";
|
||||||
|
import SuccessOrError from "$lib/components/SuccessOrError.svelte";
|
||||||
|
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
|
||||||
|
import Modal from "$lib/components/Modal.svelte";
|
||||||
|
import type { ActionData, PageServerData } from "./$types";
|
||||||
|
|
||||||
|
const { data, form }: { data: PageServerData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
let previewContent = $state(data.legalInformation.main_content);
|
||||||
|
let mainContentTextarea: HTMLTextAreaElement;
|
||||||
|
let textareaScrollTop = $state(0);
|
||||||
|
|
||||||
|
const updateScrollPercentage = () => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = mainContentTextarea;
|
||||||
|
textareaScrollTop = (scrollTop / (scrollHeight - clientHeight)) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
let sending = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SuccessOrError success={form?.success} message={form?.message} />
|
||||||
|
|
||||||
|
{#if sending}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<WebsiteEditor
|
||||||
|
id={data.website.id}
|
||||||
|
contentType={data.website.content_type}
|
||||||
|
title={data.website.title}
|
||||||
|
previewContent={previewContent ||
|
||||||
|
"Put some markdown content in main content to see a live preview here"}
|
||||||
|
previewScrollTop={textareaScrollTop}
|
||||||
|
>
|
||||||
|
<section id="legal-information">
|
||||||
|
<h2>
|
||||||
|
<a href="#legal-information">Legal information</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Static websites that do not collect user data and do not use cookies generally have minimal
|
||||||
|
legal obligations regarding privacy policies, imprints, etc. However, it may still be a good
|
||||||
|
idea to include, for example:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>A simple privacy policy stating that no personal information is collected or stored</li>
|
||||||
|
<li>
|
||||||
|
An imprint (if required by local law) with contact information for the site owner/operator
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>Always consult local laws and regulations for specific requirements in your jurisdiction.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To include a link to your legal information in the footer, you can write <code>!!legal</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/createUpdateLegalInformation"
|
||||||
|
use:enhance={() => {
|
||||||
|
sending = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
sending = false;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Main content:
|
||||||
|
<textarea
|
||||||
|
name="main-content"
|
||||||
|
rows="20"
|
||||||
|
placeholder="## Impressum
|
||||||
|
|
||||||
|
## Privacy policy"
|
||||||
|
bind:value={previewContent}
|
||||||
|
bind:this={mainContentTextarea}
|
||||||
|
onscroll={updateScrollPercentage}
|
||||||
|
required>{data.legalInformation.main_content ?? ""}</textarea
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if data.legalInformation.main_content}
|
||||||
|
<Modal id="delete-legal-information" text="Delete">
|
||||||
|
<form
|
||||||
|
action="?/deleteLegalInformation"
|
||||||
|
method="post"
|
||||||
|
use:enhance={() => {
|
||||||
|
sending = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
window.location.hash = "!";
|
||||||
|
sending = false;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Delete legal information</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Caution!</strong>
|
||||||
|
This action will remove the legal information page from the website and delete all data.
|
||||||
|
</p>
|
||||||
|
<button type="submit">Delete legal information</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</WebsiteEditor>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form[action="?/createUpdateLegalInformation"] {
|
||||||
|
margin-block-start: var(--space-s);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
|
import type { ChangeLog, User, Collab } from "$lib/db-schema";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ parent, fetch, params, cookies, url }) => {
|
||||||
|
const userFilter = url.searchParams.get("logs_filter_user");
|
||||||
|
const resourceFilter = url.searchParams.get("logs_filter_resource");
|
||||||
|
const operationFilter = url.searchParams.get("logs_filter_operation");
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
const baseFetchUrl = `${API_BASE_PREFIX}/change_log?website_id=eq.${params.websiteId}&select=id,table_name,operation,tstamp,old_value,new_value,user_id,username&order=tstamp.desc`;
|
||||||
|
|
||||||
|
if (userFilter && userFilter !== "all") {
|
||||||
|
searchParams.append("username", `eq.${userFilter}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceFilter && resourceFilter !== "all") {
|
||||||
|
searchParams.append("table_name", `eq.${resourceFilter}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operationFilter && operationFilter !== "all") {
|
||||||
|
searchParams.append("operation", `eq.${operationFilter.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const constructedFetchUrl = `${baseFetchUrl}&${searchParams.toString()}`;
|
||||||
|
|
||||||
|
const changeLogData = await fetch(constructedFetchUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${cookies.get("session_token")}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultChangeLogData = await fetch(constructedFetchUrl, {
|
||||||
|
method: "HEAD",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${cookies.get("session_token")}`,
|
||||||
|
Prefer: "count=exact"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultChangeLogCount = Number(
|
||||||
|
resultChangeLogData.headers.get("content-range")?.split("/").at(-1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const collabData = await fetch(
|
||||||
|
`${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&select=*,user!user_id(*)&order=last_modified_at.desc,added_at.desc`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${cookies.get("session_token")}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeLog: (ChangeLog & { user: { username: User["username"] } })[] =
|
||||||
|
await changeLogData.json();
|
||||||
|
const collaborators: (Collab & { user: User })[] = await collabData.json();
|
||||||
|
const { website, home } = await parent();
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeLog,
|
||||||
|
resultChangeLogCount,
|
||||||
|
website,
|
||||||
|
home,
|
||||||
|
collaborators
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import WebsiteEditor from "$lib/components/WebsiteEditor.svelte";
|
||||||
|
import DateTime from "$lib/components/DateTime.svelte";
|
||||||
|
import Modal from "$lib/components/Modal.svelte";
|
||||||
|
import type { PageServerData } from "./$types";
|
||||||
|
import diff from "fast-diff";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { tables } from "$lib/db-schema";
|
||||||
|
|
||||||
|
const { data }: { data: PageServerData } = $props();
|
||||||
|
|
||||||
|
const htmlDiff = (oldValue: string, newValue: string) => {
|
||||||
|
return diff(oldValue, newValue)
|
||||||
|
.map(([type, value]) => {
|
||||||
|
let newString = "";
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 1:
|
||||||
|
newString += `<ins>${value}</ins>`;
|
||||||
|
break;
|
||||||
|
case 0:
|
||||||
|
newString += `${value}`;
|
||||||
|
break;
|
||||||
|
case -1:
|
||||||
|
newString += `<del>${value}</del>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newString;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
let resources = $state({});
|
||||||
|
|
||||||
|
if (data.website.content_type === "Blog") {
|
||||||
|
const { user, change_log, media, docs_category, ...restTables } = tables;
|
||||||
|
resources = restTables;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.website.content_type === "Docs") {
|
||||||
|
const { user, change_log, media, ...restTables } = tables;
|
||||||
|
resources = restTables;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<WebsiteEditor
|
||||||
|
id={data.website.id}
|
||||||
|
contentType={data.website.content_type}
|
||||||
|
title={data.website.title}
|
||||||
|
previewContent={data.home.main_content}
|
||||||
|
>
|
||||||
|
<section id="logs">
|
||||||
|
<hgroup>
|
||||||
|
<h2>
|
||||||
|
<a href="#logs">Logs</a>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
<strong>{data.resultChangeLogCount}</strong>
|
||||||
|
<small>results</small>
|
||||||
|
</p>
|
||||||
|
</hgroup>
|
||||||
|
<details>
|
||||||
|
<summary>Filter</summary>
|
||||||
|
<form method="GET">
|
||||||
|
<label>
|
||||||
|
Username:
|
||||||
|
<input
|
||||||
|
list="users-{data.website.id}"
|
||||||
|
name="logs_filter_user"
|
||||||
|
value={$page.url.searchParams.get("logs_filter_user")}
|
||||||
|
/>
|
||||||
|
<datalist id="users-{data.website.id}">
|
||||||
|
<option value={data.website.user.username}></option>
|
||||||
|
{#each data.collaborators as { user: { username } }}
|
||||||
|
<option value={username}></option>
|
||||||
|
{/each}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Resource:
|
||||||
|
<select name="logs_filter_resource">
|
||||||
|
<option value="all">Show all</option>
|
||||||
|
{#each Object.keys(resources) as resource}
|
||||||
|
<option
|
||||||
|
value={resource}
|
||||||
|
selected={resource === $page.url.searchParams.get("logs_filter_resource")}
|
||||||
|
>{resource}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Operation:
|
||||||
|
<select name="logs_filter_operation">
|
||||||
|
<option value="all">Show all</option>
|
||||||
|
<option
|
||||||
|
value="insert"
|
||||||
|
selected={"insert" === $page.url.searchParams.get("logs_filter_operation")}
|
||||||
|
>Insert</option
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="update"
|
||||||
|
selected={"update" === $page.url.searchParams.get("logs_filter_operation")}
|
||||||
|
>Update</option
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="delete"
|
||||||
|
selected={"delete" === $page.url.searchParams.get("logs_filter_operation")}
|
||||||
|
>Delete</option
|
||||||
|
>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<div class="scroll-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Resource</th>
|
||||||
|
<th>Operation</th>
|
||||||
|
<th>Date and time</th>
|
||||||
|
<th>Changes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.changeLog as { id, table_name, operation, tstamp, old_value, new_value, user_id, username }}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span style:text-decoration={user_id ? "" : "line-through"}>
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{table_name}</td>
|
||||||
|
<td>{operation}</td>
|
||||||
|
<td>
|
||||||
|
<DateTime date={tstamp} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Modal id="log-{id}" text="Show" isWider={true}>
|
||||||
|
{@const oldValue = JSON.stringify(old_value, null, 2)}
|
||||||
|
{@const newValue = JSON.stringify(new_value, null, 2)}
|
||||||
|
|
||||||
|
<hgroup>
|
||||||
|
<h3>Log changes</h3>
|
||||||
|
<p>{table_name} — {operation}</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<pre style="white-space: pre-wrap">{@html htmlDiff(oldValue, newValue)}</pre>
|
||||||
|
</Modal>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</WebsiteEditor>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { md } from "$lib/utils";
|
import { type WebsiteOverview, slugify } from "$lib/utils";
|
||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { API_BASE_PREFIX } from "$lib/server/utils";
|
import { API_BASE_PREFIX } from "$lib/server/utils";
|
||||||
import { render } from "svelte/server";
|
import { render } from "svelte/server";
|
||||||
@@ -10,33 +10,9 @@ import DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
|
|||||||
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
|
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
|
||||||
import { dev } from "$app/environment";
|
import { dev } from "$app/environment";
|
||||||
|
|
||||||
interface WebsiteData {
|
export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
|
||||||
id: string;
|
|
||||||
content_type: "Blog" | "Docs";
|
|
||||||
favicon_image: string | null;
|
|
||||||
title: string;
|
|
||||||
logo_type: "text" | "image";
|
|
||||||
logo_text: string | null;
|
|
||||||
logo_image: string | null;
|
|
||||||
main_content: string;
|
|
||||||
additional_text: string;
|
|
||||||
accent_color_light_theme: string;
|
|
||||||
accent_color_dark_theme: string;
|
|
||||||
articles: {
|
|
||||||
cover_image: string | null;
|
|
||||||
title: string;
|
|
||||||
publication_date: string;
|
|
||||||
meta_description: string;
|
|
||||||
main_content: string;
|
|
||||||
}[];
|
|
||||||
categorized_articles: {
|
|
||||||
[key: string]: { title: string; publication_date: string; meta_description: string }[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) => {
|
|
||||||
const websiteOverviewData = await fetch(
|
const websiteOverviewData = await fetch(
|
||||||
`${API_BASE_PREFIX}/website_overview?id=eq.${params.websiteId}`,
|
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -47,8 +23,7 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) =
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const websiteOverview = await websiteOverviewData.json();
|
const websiteOverview: WebsiteOverview = await websiteOverviewData.json();
|
||||||
const { website } = await parent();
|
|
||||||
|
|
||||||
generateStaticFiles(websiteOverview);
|
generateStaticFiles(websiteOverview);
|
||||||
|
|
||||||
@@ -69,15 +44,14 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) =
|
|||||||
return {
|
return {
|
||||||
websiteOverview,
|
websiteOverview,
|
||||||
websitePreviewUrl,
|
websitePreviewUrl,
|
||||||
websiteProdUrl,
|
websiteProdUrl
|
||||||
website
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
publishWebsite: async ({ fetch, params, cookies }) => {
|
publishWebsite: async ({ fetch, params, cookies }) => {
|
||||||
const websiteOverviewData = await fetch(
|
const websiteOverviewData = await fetch(
|
||||||
`${API_BASE_PREFIX}/website_overview?id=eq.${params.websiteId}`,
|
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -111,54 +85,9 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean = true) => {
|
const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: boolean = true) => {
|
||||||
let head = "";
|
const fileContents = (head: string, body: string) => {
|
||||||
let body = "";
|
return `
|
||||||
|
|
||||||
switch (websiteData.content_type) {
|
|
||||||
case "Blog":
|
|
||||||
{
|
|
||||||
({ head, body } = render(BlogIndex, {
|
|
||||||
props: {
|
|
||||||
favicon: websiteData.favicon_image
|
|
||||||
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
|
|
||||||
: "",
|
|
||||||
title: websiteData.title,
|
|
||||||
logoType: websiteData.logo_type,
|
|
||||||
logo:
|
|
||||||
websiteData.logo_type === "text"
|
|
||||||
? (websiteData.logo_text ?? "")
|
|
||||||
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
|
|
||||||
mainContent: md(websiteData.main_content ?? "", false),
|
|
||||||
articles: websiteData.articles ?? [],
|
|
||||||
footerAdditionalText: md(websiteData.additional_text ?? "")
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "Docs":
|
|
||||||
{
|
|
||||||
({ head, body } = render(DocsIndex, {
|
|
||||||
props: {
|
|
||||||
favicon: websiteData.favicon_image
|
|
||||||
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
|
|
||||||
: "",
|
|
||||||
title: websiteData.title,
|
|
||||||
logoType: websiteData.logo_type,
|
|
||||||
logo:
|
|
||||||
websiteData.logo_type === "text"
|
|
||||||
? (websiteData.logo_text ?? "")
|
|
||||||
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
|
|
||||||
mainContent: md(websiteData.main_content ?? "", false),
|
|
||||||
categorizedArticles: websiteData.categorized_articles ?? [],
|
|
||||||
footerAdditionalText: md(websiteData.additional_text ?? "")
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexFileContents = `
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -168,6 +97,15 @@ const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean
|
|||||||
${body}
|
${body}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { head, body } = render(websiteData.content_type === "Blog" ? BlogIndex : DocsIndex, {
|
||||||
|
props: {
|
||||||
|
websiteOverview: websiteData,
|
||||||
|
apiUrl: API_BASE_PREFIX,
|
||||||
|
isLegalPage: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let uploadDir = "";
|
let uploadDir = "";
|
||||||
|
|
||||||
@@ -178,77 +116,36 @@ const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(uploadDir, { recursive: true });
|
await mkdir(uploadDir, { recursive: true });
|
||||||
await writeFile(join(uploadDir, "index.html"), indexFileContents);
|
await writeFile(join(uploadDir, "index.html"), fileContents(head, body));
|
||||||
await mkdir(join(uploadDir, "articles"), {
|
await mkdir(join(uploadDir, "articles"), {
|
||||||
recursive: true
|
recursive: true
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const article of websiteData.articles ?? []) {
|
for (const article of websiteData.article ?? []) {
|
||||||
const articleFileName = article.title.toLowerCase().split(" ").join("-");
|
const { head, body } = render(websiteData.content_type === "Blog" ? BlogArticle : DocsArticle, {
|
||||||
|
props: {
|
||||||
|
websiteOverview: websiteData,
|
||||||
|
article,
|
||||||
|
apiUrl: API_BASE_PREFIX
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let head = "";
|
await writeFile(
|
||||||
let body = "";
|
join(uploadDir, "articles", `${slugify(article.title)}.html`),
|
||||||
|
fileContents(head, body)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (websiteData.content_type) {
|
if (websiteData.legal_information) {
|
||||||
case "Blog":
|
const { head, body } = render(websiteData.content_type === "Blog" ? BlogIndex : DocsIndex, {
|
||||||
{
|
props: {
|
||||||
({ head, body } = render(BlogArticle, {
|
websiteOverview: websiteData,
|
||||||
props: {
|
apiUrl: API_BASE_PREFIX,
|
||||||
favicon: websiteData.favicon_image
|
isLegalPage: true
|
||||||
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
|
}
|
||||||
: "",
|
});
|
||||||
title: article.title,
|
|
||||||
logoType: websiteData.logo_type,
|
|
||||||
logo:
|
|
||||||
websiteData.logo_type === "text"
|
|
||||||
? (websiteData.logo_text ?? "")
|
|
||||||
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
|
|
||||||
coverImage: article.cover_image
|
|
||||||
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${article.cover_image}`
|
|
||||||
: "",
|
|
||||||
publicationDate: article.publication_date,
|
|
||||||
mainContent: md(article.main_content ?? ""),
|
|
||||||
footerAdditionalText: md(websiteData.additional_text ?? ""),
|
|
||||||
metaDescription: article.meta_description
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "Docs":
|
|
||||||
{
|
|
||||||
({ head, body } = render(DocsArticle, {
|
|
||||||
props: {
|
|
||||||
favicon: websiteData.favicon_image
|
|
||||||
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
|
|
||||||
: "",
|
|
||||||
title: article.title,
|
|
||||||
logoType: websiteData.logo_type,
|
|
||||||
logo:
|
|
||||||
websiteData.logo_type === "text"
|
|
||||||
? (websiteData.logo_text ?? "")
|
|
||||||
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
|
|
||||||
mainContent: md(article.main_content ?? ""),
|
|
||||||
categorizedArticles: websiteData.categorized_articles ?? [],
|
|
||||||
footerAdditionalText: md(websiteData.additional_text ?? ""),
|
|
||||||
metaDescription: article.meta_description
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const articleFileContents = `
|
await writeFile(join(uploadDir, "legal-information.html"), fileContents(head, body));
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
${head}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${body}
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
await writeFile(join(uploadDir, "articles", `${articleFileName}.html`), articleFileContents);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonStyles = await readFile(`${process.cwd()}/template-styles/common-styles.css`, {
|
const commonStyles = await readFile(`${process.cwd()}/template-styles/common-styles.css`, {
|
||||||
@@ -266,14 +163,14 @@ const generateStaticFiles = async (websiteData: WebsiteData, isPreview: boolean
|
|||||||
.concat(specificStyles)
|
.concat(specificStyles)
|
||||||
.replace(
|
.replace(
|
||||||
/--color-accent:\s*(.*?);/,
|
/--color-accent:\s*(.*?);/,
|
||||||
`--color-accent: ${websiteData.accent_color_dark_theme};`
|
`--color-accent: ${websiteData.settings.accent_color_dark_theme};`
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
/@media\s*\(prefers-color-scheme:\s*dark\)\s*{[^}]*--color-accent:\s*(.*?);/,
|
/@media\s*\(prefers-color-scheme:\s*dark\)\s*{[^}]*--color-accent:\s*(.*?);/,
|
||||||
(match) =>
|
(match) =>
|
||||||
match.replace(
|
match.replace(
|
||||||
/--color-accent:\s*(.*?);/,
|
/--color-accent:\s*(.*?);/,
|
||||||
`--color-accent: ${websiteData.accent_color_light_theme};`
|
`--color-accent: ${websiteData.settings.accent_color_light_theme};`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<WebsiteEditor
|
<WebsiteEditor
|
||||||
id={data.website.id}
|
id={data.websiteOverview.id}
|
||||||
contentType={data.website.content_type}
|
contentType={data.websiteOverview.content_type}
|
||||||
title={data.website.title}
|
title={data.websiteOverview.title}
|
||||||
previewContent={data.websitePreviewUrl}
|
previewContent={data.websitePreviewUrl}
|
||||||
fullPreview={true}
|
fullPreview={true}
|
||||||
>
|
>
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
<button type="submit">Publish</button>
|
<button type="submit">Publish</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if data.website.is_published}
|
{#if data.websiteOverview.is_published}
|
||||||
<section>
|
<section id="publication-status">
|
||||||
<h3>
|
<h3>
|
||||||
<a href="#publication-status">Publication status</a>
|
<a href="#publication-status">Publication status</a>
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -25,11 +25,12 @@
|
|||||||
|
|
||||||
--color-text: black;
|
--color-text: black;
|
||||||
--color-text-invert: white;
|
--color-text-invert: white;
|
||||||
|
--color-border: hsl(0 0% 50%);
|
||||||
--color-accent: hsl(210, 100%, 30%);
|
--color-accent: hsl(210, 100%, 30%);
|
||||||
--color-success: hsl(105, 100%, 30%);
|
--color-success: hsl(105, 100%, 30%);
|
||||||
--color-error: hsl(0, 100%, 30%);
|
--color-error: hsl(0, 100%, 30%);
|
||||||
|
|
||||||
--border-primary: 0.0625rem solid var(--bg-tertiary);
|
--border-primary: 0.0625rem solid var(--color-border);
|
||||||
--border-radius: 0.125rem;
|
--border-radius: 0.125rem;
|
||||||
|
|
||||||
/* Step -1: 14.9953px → 14.2222px */
|
/* Step -1: 14.9953px → 14.2222px */
|
||||||
@@ -222,6 +223,10 @@ a {
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a:has(img) {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
:is(h1, h2, h3, h4, h5, h6) > a {
|
:is(h1, h2, h3, h4, h5, h6) > a {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -242,6 +247,7 @@ svg,
|
|||||||
video {
|
video {
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
p,
|
p,
|
||||||
@@ -325,3 +331,13 @@ td {
|
|||||||
padding: var(--space-2xs);
|
padding: var(--space-2xs);
|
||||||
border: var(--border-primary);
|
border: var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ins {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: var(--color-text-invert);
|
||||||
|
}
|
||||||
|
|
||||||
|
del {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: var(--color-text-invert);
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByLabel("Username:").click();
|
await page.getByLabel("Username:").click();
|
||||||
await page.getByLabel("Username:").fill(collabUsername);
|
await page.getByLabel("Username:").fill(collabUsername);
|
||||||
await page.getByRole("button", { name: "Submit" }).click();
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content");
|
||||||
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const permissionLevel of permissionLevels) {
|
for (const permissionLevel of permissionLevels) {
|
||||||
@@ -142,7 +147,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Submit" }).click();
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully updated website")).toBeVisible();
|
await expect(page.getByText("Successfully updated website")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -150,14 +155,14 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
test("Delete website", async ({ page }) => {
|
test("Delete website", async ({ page }) => {
|
||||||
await page.locator("li").filter({ hasText: "Blog" }).getByRole("button").nth(1).click();
|
await page.locator("li").filter({ hasText: "Blog" }).getByRole("button").nth(1).click();
|
||||||
await page.getByRole("button", { name: "Delete website" }).click();
|
await page.getByRole("button", { name: "Delete website" }).click();
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
});
|
});
|
||||||
test("Update Global", async ({ page }) => {
|
test("Update Global", async ({ page }) => {
|
||||||
await page.getByRole("link", { name: "Blog" }).click();
|
await page.getByRole("link", { name: "Blog" }).click();
|
||||||
await page.locator("#global").getByRole("button", { name: "Submit" }).click();
|
await page.locator("#global").getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully updated global")).toBeVisible();
|
await expect(page.getByText("Successfully updated global")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -167,7 +172,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.locator("#header").getByRole("button", { name: "Submit" }).click();
|
await page.locator("#header").getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully updated header")).toBeVisible();
|
await expect(page.getByText("Successfully updated header")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -177,7 +182,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.locator("#home").getByRole("button", { name: "Submit" }).click();
|
await page.locator("#home").getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully updated home")).toBeVisible();
|
await expect(page.getByText("Successfully updated home")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -187,7 +192,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.locator("#footer").getByRole("button", { name: "Submit" }).click();
|
await page.locator("#footer").getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully updated footer")).toBeVisible();
|
await expect(page.getByText("Successfully updated footer")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -201,7 +206,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Submit" }).click();
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully created article")).toBeVisible();
|
await expect(page.getByText("Successfully created article")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -223,7 +228,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Submit" }).click();
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully updated article")).toBeVisible();
|
await expect(page.getByText("Successfully updated article")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -239,7 +244,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Delete article" }).click();
|
await page.getByRole("button", { name: "Delete article" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
}
|
}
|
||||||
if ([20, 30].includes(permissionLevel)) {
|
if ([20, 30].includes(permissionLevel)) {
|
||||||
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
||||||
@@ -248,7 +253,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Delete article" }).click();
|
await page.getByRole("button", { name: "Delete article" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 20) {
|
if (permissionLevel === 20) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
await expect(page.getByText("Successfully deleted article")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -263,7 +268,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Submit" }).click();
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if ([10, 20].includes(permissionLevel)) {
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully added")).toBeVisible();
|
await expect(page.getByText("Successfully added")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -281,7 +286,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Update collaborator" }).click();
|
await page.getByRole("button", { name: "Update collaborator" }).click();
|
||||||
|
|
||||||
if ([10, 20].includes(permissionLevel)) {
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully updated")).toBeVisible();
|
await expect(page.getByText("Successfully updated")).toBeVisible();
|
||||||
|
|
||||||
@@ -293,7 +298,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
.click();
|
.click();
|
||||||
await page.getByRole("combobox").selectOption("30");
|
await page.getByRole("combobox").selectOption("30");
|
||||||
await page.getByRole("button", { name: "Update collaborator" }).click();
|
await page.getByRole("button", { name: "Update collaborator" }).click();
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Remove collaborator", async ({ page }) => {
|
test("Remove collaborator", async ({ page }) => {
|
||||||
@@ -308,7 +313,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
||||||
|
|
||||||
if ([10, 20].includes(permissionLevel)) {
|
if ([10, 20].includes(permissionLevel)) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully removed")).toBeVisible();
|
await expect(page.getByText("Successfully removed")).toBeVisible();
|
||||||
|
|
||||||
@@ -319,7 +324,46 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
await page.getByRole("button", { name: "Remove collaborator" }).click();
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("Create/Update legal information", async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: "Blog" }).click();
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content");
|
||||||
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 30) {
|
||||||
|
await expect(page.getByText("Successfully created legal")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content updated");
|
||||||
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 30) {
|
||||||
|
await expect(page.getByText("Successfully updated legal")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("Delete legal information", async ({ page }) => {
|
||||||
|
await page
|
||||||
|
.getByRole("link", {
|
||||||
|
name: [10, 20].includes(permissionLevel) ? "Documentation" : "Blog"
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete" }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete legal information" }).click();
|
||||||
|
|
||||||
|
if (permissionLevel === 30) {
|
||||||
|
await expect(page.getByText("Successfully deleted legal")).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("Create category", async ({ page }) => {
|
test("Create category", async ({ page }) => {
|
||||||
@@ -333,7 +377,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Submit" }).click();
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully created category")).toBeVisible();
|
await expect(page.getByText("Successfully created category")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -354,7 +398,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Update category" }).click();
|
await page.getByRole("button", { name: "Update category" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully updated category")).toBeVisible();
|
await expect(page.getByText("Successfully updated category")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -371,7 +415,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Delete category" }).click();
|
await page.getByRole("button", { name: "Delete category" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
}
|
}
|
||||||
if ([20, 30].includes(permissionLevel)) {
|
if ([20, 30].includes(permissionLevel)) {
|
||||||
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
||||||
@@ -385,7 +429,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Delete category" }).click();
|
await page.getByRole("button", { name: "Delete category" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 20) {
|
if (permissionLevel === 20) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
await expect(page.getByText("Successfully deleted category")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -397,7 +441,7 @@ test.describe.serial("Collaborator tests", () => {
|
|||||||
await page.getByRole("button", { name: "Publish" }).click();
|
await page.getByRole("button", { name: "Publish" }).click();
|
||||||
|
|
||||||
if (permissionLevel === 10) {
|
if (permissionLevel === 10) {
|
||||||
await expect(page.getByText("You do not have the required")).toBeVisible();
|
await expect(page.getByText("Insufficient permissions")).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect(page.getByText("Successfully published website")).toBeVisible();
|
await expect(page.getByText("Successfully published website")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,29 @@ test.describe.serial("Website tests", () => {
|
|||||||
await expect(page.getByText("Successfully removed")).toBeVisible();
|
await expect(page.getByText("Successfully removed")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe.serial("Legal information", () => {
|
||||||
|
test("Create/Update legal information", async ({ authenticatedPage: page }) => {
|
||||||
|
await page.getByRole("link", { name: "Blog" }).click();
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content");
|
||||||
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
await expect(page.getByText("Successfully created legal")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
|
||||||
|
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content updated");
|
||||||
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
await expect(page.getByText("Successfully updated legal")).toBeVisible();
|
||||||
|
});
|
||||||
|
test("Delete legal information", async ({ authenticatedPage: page }) => {
|
||||||
|
await page.getByRole("link", { name: "Blog" }).click();
|
||||||
|
await page.getByRole("link", { name: "Legal information" }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete" }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete legal information" }).click();
|
||||||
|
await expect(page.getByText("Successfully deleted legal")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe.serial("Docs", () => {
|
test.describe.serial("Docs", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user