Merge pull request #17 from archtika/devel

Various Refactorings and QoL improvements
This commit is contained in:
Thilo Hohlt
2024-10-26 13:58:08 +02:00
committed by GitHub
81 changed files with 3321 additions and 1717 deletions

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1726463316,
"narHash": "sha256-gI9kkaH0ZjakJOKrdjaI/VbaMEo9qBbSUl93DnU7f4c=",
"lastModified": 1729256560,
"narHash": "sha256-/uilDXvCIEs3C9l73JTACm4quuHUsIHcns1c+cHUJwA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "99dc8785f6a0adac95f5e2ab05cc2e1bf666d172",
"rev": "4c2fcb090b1f3e5b47eaa7bd33913b574a11e0a0",
"type": "github"
},
"original": {

View File

@@ -23,7 +23,10 @@
in
{
api = pkgs.mkShell {
packages = with pkgs; [ postgresql_16 ];
packages = with pkgs; [
postgresql_16
postgrest
];
shellHook = ''
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"
@@ -49,6 +52,8 @@
dev-vm = self.nixosConfigurations.dev-vm.config.system.build.vm;
default = pkgs.callPackage ./nix/package.nix { };
docker = pkgs.callPackage ./nix/docker.nix { };
}
);
@@ -62,8 +67,12 @@
type = "app";
program = "${pkgs.writeShellScriptBin "api-setup" ''
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64)
WEBSITE_MAX_STORAGE_SIZE=100
WEBSITE_MAX_NUMBER_USER=3
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.jwt_secret\" TO '$JWT_SECRET'"
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.website_max_storage_size\" TO $WEBSITE_MAX_STORAGE_SIZE"
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:15432/archtika -c "ALTER DATABASE archtika SET \"app.website_max_number_user\" TO $WEBSITE_MAX_NUMBER_USER"
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:15432/archtika?sslmode=disable --migrations-dir ${self.outPath}/rest-api/db/migrations up

View File

@@ -15,6 +15,10 @@
acmeEmail = "thilo.hohlt@tutanota.com";
dnsProvider = "porkbun";
dnsEnvironmentFile = /var/lib/porkbun.env;
settings = {
disableRegistration = true;
maxWebsiteStorageSize = 250;
maxUserWebsites = 3;
};
};
}

View File

@@ -24,6 +24,9 @@
virtualisation = {
graphics = false;
memorySize = 2048;
cores = 2;
diskSize = 10240;
sharedDirectories = {
websites = {
source = "/var/www/archtika-websites";
@@ -49,6 +52,15 @@
postgresql = {
enable = true;
package = pkgs.postgresql_16;
/*
PL/Perl:
overrideAttrs (
finalAttrs: previousAttrs: {
buildInputs = previousAttrs.buildInputs ++ [ pkgs.perl ];
configureFlags = previousAttrs.configureFlags ++ [ "--with-perl" ];
}
);
*/
ensureDatabases = [ "archtika" ];
authentication = lib.mkForce ''
local all all trust
@@ -59,6 +71,11 @@
};
nginx = {
enable = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
recommendedZstdSettings = true;
recommendedOptimisation = true;
virtualHosts."_" = {
listen = [
{
@@ -67,13 +84,15 @@
}
];
locations = {
"/previews/" = {
alias = "/var/www/archtika-websites/previews/";
index = "index.html";
tryFiles = "$uri $uri/ $uri.html =404";
};
"/" = {
root = "/var/www/archtika-websites";
index = "index.html";
tryFiles = "$uri $uri/ $uri.html =404";
extraConfig = ''
autoindex on;
'';
};
};
};

45
nix/docker.nix Normal file
View File

@@ -0,0 +1,45 @@
{
pkgs,
...
}:
# Behaviour of the Nix module needs to be replicated, which includes PostgreSQL, NGINX, ACME (DNS01), env variables, etc.
# Basic initialisation template can be found below
let
archtika = pkgs.callPackage ./package.nix { };
postgresConf = pkgs.writeText "postgres.conf" ''
'';
nginxConf = pkgs.writeText "nginx.conf" ''
'';
entrypoint = pkgs.writeShellScriptBin "entrypoint" ''
'';
in
pkgs.dockerTools.buildLayeredImage {
name = "archtika";
tag = "latest";
contents = [
archtika
entrypoint
pkgs.postgresql_16
pkgs.nginx
pkgs.acme-sh
pkgs.bash
pkgs.coreutils
];
config = {
Cmd = [ "${entrypoint}/bin/entrypoint" ];
ExposedPorts = {
"80" = { };
"443" = { };
};
Volumes = {
"/var/lib/postgresql/data" = { };
};
};
}

View File

@@ -76,11 +76,27 @@ in
description = "API secrets for the DNS-01 challenge (required for wildcard domains).";
};
settings = mkOption {
type = types.submodule {
options = {
disableRegistration = mkOption {
type = types.bool;
default = false;
description = "By default any user can create an account. That behavior can be disabled by using this option.";
};
maxUserWebsites = mkOption {
type = types.int;
default = 2;
description = "Maximum number of websites allowed per user by default.";
};
maxWebsiteStorageSize = mkOption {
type = types.int;
default = 500;
description = "Maximum amount of disk space in MB allowed per user website by default.";
};
};
};
};
};
config = mkIf cfg.enable {
@@ -91,7 +107,7 @@ in
users.groups.${cfg.group} = { };
systemd.tmpfiles.rules = [ "d /var/www/archtika-websites 0755 ${cfg.user} ${cfg.group} -" ];
systemd.tmpfiles.rules = [ "d /var/www/archtika-websites 0777 ${cfg.user} ${cfg.group} -" ];
systemd.services.archtika-api = {
description = "archtika API service";
@@ -105,12 +121,15 @@ in
User = cfg.user;
Group = cfg.group;
Restart = "always";
WorkingDirectory = "${cfg.package}/rest-api";
};
script = ''
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64)
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.jwt_secret\" TO '$JWT_SECRET'"
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_storage_size\" TO ${toString cfg.settings.maxWebsiteStorageSize}"
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_number_user\" TO ${toString cfg.settings.maxUserWebsites}"
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:5432/archtika?sslmode=disable --migrations-dir ${cfg.package}/rest-api/db/migrations up
@@ -131,7 +150,7 @@ in
};
script = ''
REGISTRATION_IS_DISABLED=${toString cfg.disableRegistration} BODY_SIZE_LIMIT=10M ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app
REGISTRATION_IS_DISABLED=${toString cfg.settings.disableRegistration} BODY_SIZE_LIMIT=10M ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app
'';
};
@@ -188,7 +207,7 @@ in
default_type application/json;
'';
};
"/api/rpc/register" = mkIf cfg.disableRegistration {
"/api/rpc/register" = mkIf cfg.settings.disableRegistration {
extraConfig = ''
deny all;
'';

View File

@@ -9,10 +9,16 @@ CREATE ROLE anon NOLOGIN NOINHERIT;
CREATE ROLE authenticated_user NOLOGIN NOINHERIT;
CREATE ROLE administrator NOLOGIN;
GRANT anon TO authenticator;
GRANT authenticated_user TO authenticator;
GRANT administrator TO authenticator;
GRANT authenticated_user TO administrator;
GRANT USAGE ON SCHEMA api TO anon;
GRANT USAGE ON SCHEMA api TO authenticated_user;
@@ -23,9 +29,10 @@ ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
CREATE TABLE internal.user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3),
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
password_hash CHAR(60) NOT NULL,
role NAME NOT NULL DEFAULT 'authenticated_user',
user_role NAME NOT NULL DEFAULT 'authenticated_user',
max_number_websites INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_number_user') ::INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP()
);
@@ -34,11 +41,11 @@ CREATE TABLE internal.website (
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
max_storage_size INT NOT NULL DEFAULT CURRENT_SETTING('app.website_max_storage_size') ::INT,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE TABLE internal.media (
@@ -74,7 +81,8 @@ CREATE TABLE internal.header (
CREATE TABLE internal.home (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''),
main_content VARCHAR(200000) NOT NULL CHECK (TRIM(main_content) != ''),
meta_description VARCHAR(250) CHECK (TRIM(meta_description) != ''),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
@@ -84,7 +92,7 @@ CREATE TABLE internal.docs_category (
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
category_weight INTEGER CHECK (category_weight >= 0) NOT NULL,
category_weight INT CHECK (category_weight >= 0) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
@@ -101,13 +109,12 @@ CREATE TABLE internal.article (
meta_author VARCHAR(100) CHECK (TRIM(meta_author) != ''),
cover_image UUID REFERENCES internal.media (id) ON DELETE SET NULL,
publication_date DATE,
main_content TEXT CHECK (TRIM(main_content) != ''),
main_content VARCHAR(200000) CHECK (TRIM(main_content) != ''),
category UUID REFERENCES internal.docs_category (id) ON DELETE SET NULL,
article_weight INTEGER CHECK (article_weight IS NULL OR article_weight >= 0),
article_weight INT CHECK (article_weight IS NULL OR article_weight >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
title_description_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(title, '') || ' ' || COALESCE(meta_description, ''))) STORED,
UNIQUE (website_id, category, article_weight)
);
@@ -120,7 +127,7 @@ CREATE TABLE internal.footer (
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) != ''),
main_content VARCHAR(200000) NOT NULL CHECK (TRIM(main_content) != ''),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
@@ -129,7 +136,7 @@ CREATE TABLE internal.legal_information (
CREATE TABLE internal.collab (
website_id UUID REFERENCES internal.website (id) ON DELETE CASCADE,
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE,
permission_level INTEGER CHECK (permission_level IN (10, 20, 30)) NOT NULL DEFAULT 10,
permission_level INT CHECK (permission_level IN (10, 20, 30)) NOT NULL DEFAULT 10,
added_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
@@ -167,6 +174,8 @@ DROP ROLE anon;
DROP ROLE authenticated_user;
DROP ROLE administrator;
DROP ROLE authenticator;
ALTER DEFAULT PRIVILEGES GRANT EXECUTE ON FUNCTIONS TO PUBLIC;

View File

@@ -1,5 +1,5 @@
-- migrate:up
CREATE FUNCTION pgrst_watch ()
CREATE FUNCTION internal.pgrst_watch ()
RETURNS EVENT_TRIGGER
AS $$
BEGIN
@@ -10,10 +10,10 @@ $$
LANGUAGE plpgsql;
CREATE EVENT TRIGGER pgrst_watch ON ddl_command_end
EXECUTE FUNCTION pgrst_watch ();
EXECUTE FUNCTION internal.pgrst_watch ();
-- migrate:down
DROP EVENT TRIGGER pgrst_watch;
DROP FUNCTION pgrst_watch ();
DROP FUNCTION internal.pgrst_watch;

View File

@@ -13,9 +13,9 @@ BEGIN
FROM
pg_roles AS r
WHERE
r.rolname = NEW.role)) THEN
r.rolname = NEW.user_role)) THEN
RAISE foreign_key_violation
USING message = 'Unknown database role: ' || NEW.role;
USING message = 'Unknown database role: ' || NEW.user_role;
END IF;
RETURN NULL;
END
@@ -48,7 +48,7 @@ CREATE FUNCTION internal.user_role (username TEXT, pass TEXT, OUT role_name NAME
AS $$
BEGIN
SELECT
ROLE INTO role_name
u.user_role INTO role_name
FROM
internal.user AS u
WHERE
@@ -96,8 +96,17 @@ BEGIN
RAISE invalid_parameter_value
USING message = 'Password must contain at least one special character';
ELSE
INSERT INTO internal.user (username, password_hash)
VALUES (register.username, register.pass)
INSERT INTO internal.user (username, password_hash, user_role)
SELECT
register.username,
register.pass,
CASE WHEN COUNT(*) = 0 THEN
'administrator'
ELSE
'authenticated_user'
END
FROM
internal.user
RETURNING
id INTO user_id;
END IF;
@@ -111,7 +120,7 @@ AS $$
DECLARE
_role NAME;
_user_id UUID;
_exp INTEGER;
_exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 86400;
BEGIN
SELECT
internal.user_role (login.username, login.pass) INTO _role;
@@ -120,12 +129,11 @@ BEGIN
USING message = 'Invalid username or password';
ELSE
SELECT
id INTO _user_id
u.id INTO _user_id
FROM
internal.user AS u
WHERE
u.username = login.username;
_exp := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INTEGER + 86400;
SELECT
SIGN(JSON_BUILD_OBJECT('role', _role, 'user_id', _user_id, 'username', login.username, 'exp', _exp), CURRENT_SETTING('app.jwt_secret')) INTO token;
END IF;
@@ -155,28 +163,28 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.register (TEXT, TEXT) TO anon;
GRANT EXECUTE ON FUNCTION api.register TO anon;
GRANT EXECUTE ON FUNCTION api.login (TEXT, TEXT) TO anon;
GRANT EXECUTE ON FUNCTION api.login TO anon;
GRANT EXECUTE ON FUNCTION api.delete_account (TEXT) TO authenticated_user;
GRANT EXECUTE ON FUNCTION api.delete_account TO authenticated_user;
-- migrate:down
DROP TRIGGER encrypt_pass ON internal.user;
DROP TRIGGER ensure_user_role_exists ON internal.user;
DROP FUNCTION api.register (TEXT, TEXT);
DROP FUNCTION api.register;
DROP FUNCTION api.login (TEXT, TEXT);
DROP FUNCTION api.login;
DROP FUNCTION api.delete_account (TEXT);
DROP FUNCTION api.delete_account;
DROP FUNCTION internal.user_role (TEXT, TEXT);
DROP FUNCTION internal.user_role;
DROP FUNCTION internal.encrypt_pass ();
DROP FUNCTION internal.encrypt_pass;
DROP FUNCTION internal.check_role_exists ();
DROP FUNCTION internal.check_role_exists;
DROP EXTENSION pgjwt;

View File

@@ -15,7 +15,9 @@ CREATE VIEW api.user WITH ( security_invoker = ON
) AS
SELECT
id,
username
username,
created_at,
max_number_websites
FROM
internal.user;
@@ -87,7 +89,25 @@ AS $$
DECLARE
_website_id UUID;
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
_user_website_count INT := (
SELECT
COUNT(*)
FROM
internal.website AS w
WHERE
w.user_id = _user_id);
_user_max_websites_allowed_count INT := (
SELECT
u.max_number_websites
FROM
internal.user AS u
WHERE
id = _user_id);
BEGIN
IF (_user_website_count + 1 > _user_max_websites_allowed_count) THEN
RAISE invalid_parameter_value
USING message = FORMAT('Limit of %s websites exceeded', _user_max_websites_allowed_count);
END IF;
INSERT INTO internal.website (content_type, title)
VALUES (create_website.content_type, create_website.title)
RETURNING
@@ -99,17 +119,7 @@ BEGIN
INSERT INTO internal.home (website_id, main_content)
VALUES (_website_id, '## About
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates.
It is also possible to add contributors to your sites, which is very useful for larger projects where, for example, several people are constantly working on the documentation.
## How it works
For the backend, PostgreSQL is used in combination with PostgREST to create a RESTful API. JSON web tokens along with row-level security control authentication and authorisation flows.
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.');
archtika is a FLOSS, modern, performant and lightweight CMS (Content Mangement System) in the form of a web application. It allows you to easily create, manage and publish minimal, responsive and SEO friendly blogging and documentation websites with official, professionally designed templates. It is also possible to add contributors to your sites, which is very useful for larger projects where, for example, several people are constantly working on the documentation.');
INSERT INTO internal.footer (website_id, additional_text)
VALUES (_website_id, 'archtika is a free, open, modern, performant and lightweight CMS');
website_id := _website_id;
@@ -118,7 +128,7 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.create_website (VARCHAR(10), VARCHAR(50)) TO authenticated_user;
GRANT EXECUTE ON FUNCTION api.create_website TO authenticated_user;
-- Security invoker only works on views if the user has access to the underlying table
GRANT SELECT ON internal.user TO authenticated_user;
@@ -139,7 +149,7 @@ GRANT SELECT, UPDATE (logo_type, logo_text, logo_image) ON internal.header TO au
GRANT SELECT, UPDATE ON api.header TO authenticated_user;
GRANT SELECT, UPDATE (main_content) ON internal.home TO authenticated_user;
GRANT SELECT, UPDATE (main_content, meta_description) ON internal.home TO authenticated_user;
GRANT SELECT, UPDATE ON api.home TO authenticated_user;
@@ -164,7 +174,7 @@ GRANT SELECT, INSERT (website_id, user_id, permission_level), UPDATE (permission
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;
-- migrate:down
DROP FUNCTION api.create_website (VARCHAR(10), VARCHAR(50));
DROP FUNCTION api.create_website;
DROP VIEW api.collab;

View File

@@ -21,7 +21,7 @@ ALTER TABLE internal.legal_information ENABLE ROW LEVEL SECURITY;
ALTER TABLE internal.collab ENABLE ROW LEVEL SECURITY;
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INTEGER, collaborator_permission_level INTEGER DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE, OUT has_access BOOLEAN)
CREATE FUNCTION internal.user_has_website_access (website_id UUID, required_permission INT, collaborator_permission_level INT DEFAULT NULL, collaborator_user_id UUID DEFAULT NULL, article_user_id UUID DEFAULT NULL, raise_error BOOLEAN DEFAULT TRUE, OUT has_access BOOLEAN)
AS $$
DECLARE
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
@@ -63,19 +63,29 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION internal.user_has_website_access (UUID, INTEGER, INTEGER, UUID, UUID, BOOLEAN) TO authenticated_user;
GRANT EXECUTE ON FUNCTION internal.user_has_website_access TO authenticated_user;
CREATE POLICY view_user ON internal.user
FOR SELECT
USING (TRUE);
CREATE POLICY update_user ON internal.user
FOR UPDATE
USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator');
CREATE POLICY delete_user ON internal.user
FOR DELETE
USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator');
CREATE POLICY view_websites ON internal.website
FOR SELECT
USING (internal.user_has_website_access (id, 10, raise_error => FALSE));
USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator'
OR internal.user_has_website_access (id, 10, raise_error => FALSE));
CREATE POLICY update_website ON internal.website
FOR UPDATE
USING (internal.user_has_website_access (id, 20));
USING ((CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'role') = 'administrator'
OR internal.user_has_website_access (id, 30));
CREATE POLICY delete_website ON internal.website
FOR DELETE
@@ -180,6 +190,10 @@ CREATE POLICY delete_collaborations ON internal.collab
-- migrate:down
DROP POLICY view_user ON internal.user;
DROP POLICY update_user ON internal.user;
DROP POLICY delete_user ON internal.user;
DROP POLICY view_websites ON internal.website;
DROP POLICY delete_website ON internal.website;
@@ -234,7 +248,7 @@ DROP POLICY update_collaborations ON internal.collab;
DROP POLICY delete_collaborations ON internal.collab;
DROP FUNCTION internal.user_has_website_access (UUID, INTEGER, INTEGER, UUID, UUID, BOOLEAN);
DROP FUNCTION internal.user_has_website_access;
ALTER TABLE internal.user DISABLE ROW LEVEL SECURITY;

View File

@@ -7,11 +7,11 @@ DECLARE
BEGIN
IF (NOT EXISTS (
SELECT
id
u.id
FROM
internal.user
internal.user AS u
WHERE
id = _user_id)) THEN
u.id = _user_id)) THEN
RETURN COALESCE(NEW, OLD);
END IF;
IF TG_OP != 'DELETE' THEN
@@ -97,5 +97,5 @@ DROP TRIGGER update_legal_information_last_modified ON internal.legal_informatio
DROP TRIGGER update_collab_last_modified ON internal.collab;
DROP FUNCTION internal.update_last_modified ();
DROP FUNCTION internal.update_last_modified;

View File

@@ -26,5 +26,5 @@ CREATE CONSTRAINT TRIGGER check_user_not_website_owner
-- migrate:down
DROP TRIGGER check_user_not_website_owner ON internal.collab;
DROP FUNCTION internal.check_user_not_website_owner ();
DROP FUNCTION internal.check_user_not_website_owner;

View File

@@ -6,13 +6,31 @@ 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', 'image/avif', 'image/gif', 'image/svg+xml'];
_max_file_size INT := 5 * 1024 * 1024;
_max_file_size BIGINT := 5 * 1024 * 1024;
_has_access BOOLEAN;
_mimetype TEXT;
BEGIN
_has_access = internal.user_has_website_access (_website_id, 20);
_mimetype := CASE WHEN SUBSTRING($1 FROM 1 FOR 8) = '\x89504E470D0A1A0A'::BYTEA THEN
'image/png'
WHEN SUBSTRING($1 FROM 1 FOR 3) = '\xFFD8FF'::BYTEA THEN
'image/jpeg'
WHEN SUBSTRING($1 FROM 1 FOR 4) = '\x52494646'::BYTEA
AND SUBSTRING($1 FROM 9 FOR 4) = '\x57454250'::BYTEA THEN
'image/webp'
WHEN SUBSTRING($1 FROM 5 FOR 7) = '\x66747970617669'::BYTEA THEN
'image/avif'
WHEN SUBSTRING($1 FROM 1 FOR 6) = '\x474946383761'::BYTEA
OR SUBSTRING($1 FROM 1 FOR 6) = '\x474946383961'::BYTEA THEN
'image/gif'
WHEN SUBSTRING($1 FROM 1 FOR 5) = '\x3C3F786D6C'::BYTEA
OR SUBSTRING($1 FROM 1 FOR 4) = '\x3C737667'::BYTEA THEN
'image/svg+xml'
ELSE
NULL
END;
IF OCTET_LENGTH($1) = 0 THEN
RAISE invalid_parameter_value
USING message = 'No file data was provided';
@@ -21,10 +39,10 @@ BEGIN
SELECT
UNNEST(_allowed_mimetypes))) THEN
RAISE invalid_parameter_value
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp';
USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp, avif, gif, svg';
ELSIF OCTET_LENGTH($1) > _max_file_size THEN
RAISE program_limit_exceeded
USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024));
USING message = FORMAT('File size exceeds the maximum limit of %s', PG_SIZE_PRETTY(_max_file_size));
ELSE
INSERT INTO internal.media (website_id, blob, mimetype, original_name)
VALUES (_website_id, $1, _mimetype, _original_filename)
@@ -56,7 +74,7 @@ BEGIN
SELECT
m.blob
FROM
internal.media m
internal.media AS m
WHERE
m.id = retrieve_file.id INTO _blob;
IF FOUND THEN
@@ -70,16 +88,16 @@ $$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user;
GRANT EXECUTE ON FUNCTION api.upload_file TO authenticated_user;
GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO anon;
GRANT EXECUTE ON FUNCTION api.retrieve_file TO anon;
GRANT EXECUTE ON FUNCTION api.retrieve_file (UUID) TO authenticated_user;
GRANT EXECUTE ON FUNCTION api.retrieve_file TO authenticated_user;
-- migrate:down
DROP FUNCTION api.upload_file (BYTEA);
DROP FUNCTION api.upload_file;
DROP FUNCTION api.retrieve_file (UUID);
DROP FUNCTION api.retrieve_file;
DROP DOMAIN "*/*";

View File

@@ -13,102 +13,6 @@ CREATE TABLE internal.change_log (
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
@@ -120,26 +24,141 @@ GRANT SELECT ON internal.change_log TO authenticated_user;
GRANT SELECT ON api.change_log TO authenticated_user;
ALTER TABLE internal.change_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY view_change_log ON internal.change_log
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
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;
_new_value HSTORE;
BEGIN
IF (NOT EXISTS (
SELECT
u.id
FROM
internal.user AS u
WHERE
u.id = _user_id) OR REGEXP_REPLACE((to_jsonb (OLD.*) - 'last_modified_at' - 'last_modified_by')::TEXT, '\r\n|\r', '\n', 'g') = REGEXP_REPLACE((to_jsonb (NEW.*) - 'last_modified_at' - 'last_modified_by')::TEXT, '\r\n|\r', '\n', 'g')) 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
_new_value := CASE WHEN TG_TABLE_NAME = 'media' THEN
HSTORE (NEW) - 'blob'::TEXT
ELSE
HSTORE (NEW)
END;
INSERT INTO internal.change_log (website_id, table_name, operation, new_value)
VALUES (_website_id, TG_TABLE_NAME, TG_OP, _new_value);
ELSIF (TG_OP = 'UPDATE'
AND EXISTS (
SELECT
w.id
FROM
internal.website AS w
WHERE
w.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
w.id
FROM
internal.website AS w
WHERE
w.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 track_changes_website
AFTER UPDATE ON internal.website
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_media
AFTER INSERT ON internal.media
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_settings
AFTER UPDATE ON internal.settings
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_header
AFTER UPDATE ON internal.header
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_home
AFTER UPDATE ON internal.home
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_article
AFTER INSERT OR UPDATE OR DELETE ON internal.article
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_docs_category
AFTER INSERT OR UPDATE OR DELETE ON internal.docs_category
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_footer
AFTER UPDATE ON internal.footer
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_legal_information
AFTER INSERT OR UPDATE OR DELETE ON internal.legal_information
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
CREATE TRIGGER track_changes_collab
AFTER INSERT OR UPDATE OR DELETE ON internal.collab
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-- migrate:down
DROP TRIGGER website_track_changes ON internal.website;
DROP TRIGGER track_changes_website ON internal.website;
DROP TRIGGER settings_track_changes ON internal.settings;
DROP TRIGGER track_changes_media ON internal.media;
DROP TRIGGER header_track_changes ON internal.header;
DROP TRIGGER track_changes_settings ON internal.settings;
DROP TRIGGER home_track_changes ON internal.home;
DROP TRIGGER track_changes_header ON internal.header;
DROP TRIGGER article_track_changes ON internal.article;
DROP TRIGGER track_changes_home ON internal.home;
DROP TRIGGER docs_category_track_changes ON internal.docs_category;
DROP TRIGGER track_changes_article ON internal.article;
DROP TRIGGER footer_track_changes ON internal.footer;
DROP TRIGGER track_changes_docs_category ON internal.docs_category;
DROP TRIGGER legal_information_track_changes ON internal.legal_information;
DROP TRIGGER track_changes_footer ON internal.footer;
DROP TRIGGER collab_track_changes ON internal.collab;
DROP TRIGGER track_changes_legal_information ON internal.legal_information;
DROP FUNCTION internal.track_changes ();
DROP TRIGGER track_changes_collab ON internal.collab;
DROP FUNCTION internal.track_changes;
DROP VIEW api.change_log;

View File

@@ -41,13 +41,13 @@ CREATE TRIGGER update_domain_prefix_last_modified
FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified ();
CREATE TRIGGER domain_prefix_track_changes
CREATE TRIGGER track_changes_domain_prefix
AFTER INSERT OR UPDATE OR DELETE ON internal.domain_prefix
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-- migrate:down
DROP TRIGGER domain_prefix_track_changes ON internal.domain_prefix;
DROP TRIGGER track_changes_domain_prefix ON internal.domain_prefix;
DROP TRIGGER update_domain_prefix_last_modified ON internal.domain_prefix;

View File

@@ -0,0 +1,185 @@
-- migrate:up
CREATE FUNCTION api.user_websites_storage_size ()
RETURNS TABLE (
website_id UUID,
website_title VARCHAR(50),
storage_size_bytes BIGINT,
storage_size_pretty TEXT,
max_storage_bytes BIGINT,
max_storage_pretty TEXT,
diff_storage_pretty TEXT
)
AS $$
DECLARE
_user_id UUID := (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id')::UUID;
_tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'domain_prefix', 'footer', 'header', 'home', 'legal_information', 'media', 'settings', 'change_log'];
_query TEXT;
_union_queries TEXT := '';
BEGIN
FOR i IN 1..ARRAY_LENGTH(_tables, 1)
LOOP
_union_queries := _union_queries || FORMAT('
SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%s AS t WHERE t.website_id = w.id', _tables[i]);
IF i < ARRAY_LENGTH(_tables, 1) THEN
_union_queries := _union_queries || ' UNION ALL ';
END IF;
END LOOP;
_query := FORMAT('
SELECT
w.id AS website_id,
w.title AS website_title,
COALESCE(SUM(sizes.total_size), 0)::BIGINT AS storage_size_bytes,
PG_SIZE_PRETTY(COALESCE(SUM(sizes.total_size), 0)) AS storage_size_pretty,
(w.max_storage_size::BIGINT * 1024 * 1024) AS max_storage_bytes,
PG_SIZE_PRETTY(w.max_storage_size::BIGINT * 1024 * 1024) AS max_storage_pretty,
PG_SIZE_PRETTY((w.max_storage_size::BIGINT * 1024 * 1024) - COALESCE(SUM(sizes.total_size), 0)) AS diff_storage_pretty
FROM
internal.website AS w
LEFT JOIN LATERAL (
%s
) AS sizes(total_size) ON TRUE
WHERE
w.user_id = $1
GROUP BY
w.id,
w.title
ORDER BY
storage_size_bytes DESC', _union_queries);
RETURN QUERY EXECUTE _query
USING _user_id;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION api.user_websites_storage_size TO authenticated_user;
CREATE FUNCTION internal.prevent_website_storage_size_excess ()
RETURNS TRIGGER
AS $$
DECLARE
_website_id UUID := NEW.website_id;
_current_size BIGINT;
_size_difference BIGINT := PG_COLUMN_SIZE(NEW) - COALESCE(PG_COLUMN_SIZE(OLD), 0);
_max_storage_mb INT := (
SELECT
w.max_storage_size
FROM
internal.website AS w
WHERE
w.id = _website_id);
_max_storage_bytes BIGINT := _max_storage_mb::BIGINT * 1024 * 1024;
_tables TEXT[] := ARRAY['article', 'collab', 'docs_category', 'domain_prefix', 'footer', 'header', 'home', 'legal_information', 'media', 'settings', 'change_log'];
_union_queries TEXT := '';
_query TEXT;
BEGIN
FOR i IN 1..ARRAY_LENGTH(_tables, 1)
LOOP
_union_queries := _union_queries || FORMAT('
SELECT SUM(PG_COLUMN_SIZE(t)) FROM internal.%s AS t WHERE t.website_id = $1', _tables[i]);
IF i < ARRAY_LENGTH(_tables, 1) THEN
_union_queries := _union_queries || ' UNION ALL ';
END IF;
END LOOP;
_query := FORMAT('
SELECT COALESCE(SUM(sizes.total_size), 0)::BIGINT
FROM (%s) AS sizes(total_size)', _union_queries);
EXECUTE _query INTO _current_size
USING _website_id;
IF (_current_size + _size_difference) > _max_storage_bytes THEN
RAISE program_limit_exceeded
USING message = FORMAT('Storage limit exceeded. Current size: %s, Max size: %s', PG_SIZE_PRETTY(_current_size), PG_SIZE_PRETTY(_max_storage_bytes));
END IF;
RETURN NEW;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
CREATE TRIGGER _prevent_storage_excess_article
BEFORE INSERT OR UPDATE ON internal.article
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_collab
BEFORE INSERT OR UPDATE ON internal.collab
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_docs_category
BEFORE INSERT OR UPDATE ON internal.docs_category
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_domain_prefix
BEFORE INSERT OR UPDATE ON internal.domain_prefix
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_footer
BEFORE UPDATE ON internal.footer
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_header
BEFORE UPDATE ON internal.header
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_home
BEFORE UPDATE ON internal.home
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_legal_information
BEFORE INSERT OR UPDATE ON internal.legal_information
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_media
BEFORE INSERT ON internal.media
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
CREATE TRIGGER _prevent_storage_excess_settings
BEFORE UPDATE ON internal.settings
FOR EACH ROW
EXECUTE FUNCTION internal.prevent_website_storage_size_excess ();
GRANT UPDATE (max_storage_size) ON internal.website TO administrator;
GRANT UPDATE, DELETE ON internal.user TO administrator;
GRANT UPDATE, DELETE ON api.user TO administrator;
-- migrate:down
DROP FUNCTION api.user_websites_storage_size;
DROP TRIGGER _prevent_storage_excess_article ON internal.article;
DROP TRIGGER _prevent_storage_excess_collab ON internal.collab;
DROP TRIGGER _prevent_storage_excess_docs_category ON internal.docs_category;
DROP TRIGGER _prevent_storage_excess_domain_prefix ON internal.domain_prefix;
DROP TRIGGER _prevent_storage_excess_footer ON internal.footer;
DROP TRIGGER _prevent_storage_excess_header ON internal.header;
DROP TRIGGER _prevent_storage_excess_home ON internal.home;
DROP TRIGGER _prevent_storage_excess_legal_information ON internal.legal_information;
DROP TRIGGER _prevent_storage_excess_media ON internal.media;
DROP TRIGGER _prevent_storage_excess_settings ON internal.settings;
DROP FUNCTION internal.prevent_website_storage_size_excess;
REVOKE UPDATE (max_storage_size) ON internal.website FROM administrator;
REVOKE UPDATE, DELETE ON internal.user FROM administrator;
REVOKE UPDATE, DELETE ON api.user FROM administrator;

View File

@@ -0,0 +1,54 @@
-- migrate:up
CREATE FUNCTION internal.cleanup_filesystem ()
RETURNS TRIGGER
AS $$
DECLARE
_website_id UUID;
_domain_prefix VARCHAR(16);
_base_path CONSTANT TEXT := '/var/www/archtika-websites/';
_preview_path TEXT;
_prod_path TEXT;
BEGIN
IF TG_TABLE_NAME = 'website' THEN
_website_id := OLD.id;
ELSE
_website_id := OLD.website_id;
END IF;
SELECT
d.prefix INTO _domain_prefix
FROM
internal.domain_prefix d
WHERE
d.website_id = _website_id;
_preview_path := _base_path || 'previews/' || _website_id;
_prod_path := _base_path || COALESCE(_domain_prefix, _website_id::TEXT);
IF TG_TABLE_NAME = 'website' THEN
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf %s''', _preview_path);
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -rf %s''', _prod_path);
ELSE
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _preview_path);
EXECUTE FORMAT('COPY (SELECT '''') TO PROGRAM ''rm -f %s/legal-information.html''', _prod_path);
END IF;
RETURN OLD;
END;
$$
LANGUAGE plpgsql
SECURITY DEFINER;
CREATE TRIGGER _cleanup_filesystem_website
BEFORE DELETE ON internal.website
FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem ();
CREATE TRIGGER _cleanup_filesystem_legal_information
BEFORE DELETE ON internal.legal_information
FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem ();
-- migrate:down
DROP TRIGGER _cleanup_filesystem_website ON internal.website;
DROP TRIGGER _cleanup_filesystem_legal_information ON internal.legal_information;
DROP FUNCTION internal.cleanup_filesystem;

View File

@@ -8,18 +8,19 @@
"name": "web-app",
"version": "0.0.1",
"dependencies": {
"fast-diff": "1.3.0",
"diff-match-patch": "1.0.5",
"highlight.js": "11.10.0",
"isomorphic-dompurify": "2.15.0",
"marked": "14.1.2",
"marked-highlight": "2.1.4"
},
"devDependencies": {
"@playwright/test": "1.46.0",
"@playwright/test": "1.47.0",
"@sveltejs/adapter-auto": "3.2.5",
"@sveltejs/adapter-node": "5.2.3",
"@sveltejs/kit": "2.5.28",
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
"@types/diff-match-patch": "1.0.36",
"@types/eslint": "9.6.1",
"@types/eslint__js": "8.42.3",
"@types/eslint-config-prettier": "6.11.3",
@@ -764,13 +765,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz",
"integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==",
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.0.tgz",
"integrity": "sha512-SgAdlSwYVpToI4e/IH19IHHWvoijAYH5hu2MWSXptRypLSnzj51PcGD+rsOXFayde4P9ZLi+loXVwArg6IUkCA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.46.0"
"playwright": "1.47.0"
},
"bin": {
"playwright": "cli.js"
@@ -1215,6 +1216,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/diff-match-patch": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
@@ -2044,6 +2052,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
"license": "Apache-2.0"
},
"node_modules/dompurify": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
@@ -2485,12 +2499,6 @@
"dev": true,
"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": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@@ -3647,13 +3655,13 @@
}
},
"node_modules/playwright": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz",
"integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==",
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.0.tgz",
"integrity": "sha512-jOWiRq2pdNAX/mwLiwFYnPHpEZ4rM+fRSQpRHwEwZlP2PUANvL3+aJOF/bvISMhFD30rqMxUB4RJx9aQbfh4Ww==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.46.0"
"playwright-core": "1.47.0"
},
"bin": {
"playwright": "cli.js"
@@ -3666,9 +3674,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz",
"integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==",
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.0.tgz",
"integrity": "sha512-1DyHT8OqkcfCkYUD9zzUTfg7EfTd+6a8MkD/NWOvjo0u/SCNd5YmY/lJwFvUZOxJbWNds+ei7ic2+R/cRz/PDg==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -14,11 +14,12 @@
"gents": "pg-to-ts generate -c postgres://postgres@localhost:15432/archtika -o src/lib/db-schema.ts -s internal"
},
"devDependencies": {
"@playwright/test": "1.46.0",
"@playwright/test": "1.47.0",
"@sveltejs/adapter-auto": "3.2.5",
"@sveltejs/adapter-node": "5.2.3",
"@sveltejs/kit": "2.5.28",
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
"@types/diff-match-patch": "1.0.36",
"@types/eslint": "9.6.1",
"@types/eslint__js": "8.42.3",
"@types/eslint-config-prettier": "6.11.3",
@@ -38,7 +39,7 @@
},
"type": "module",
"dependencies": {
"fast-diff": "1.3.0",
"diff-match-patch": "1.0.5",
"highlight.js": "11.10.0",
"isomorphic-dompurify": "2.15.0",
"marked": "14.1.2",

View File

@@ -9,15 +9,36 @@ const config: PlaywrightTestConfig = {
baseURL: "http://localhost:4173",
video: "retain-on-failure"
},
testDir: "tests",
testDir: "./tests",
testMatch: /(.+\.)?(test|spec)\.ts/,
retries: 3,
// Firefox and Webkit are not packaged yet, see https://github.com/NixOS/nixpkgs/issues/288826
// https://github.com/NixOS/nixpkgs/issues/288826
projects: [
{
name: "Register users",
testMatch: /global-setup\.ts/,
teardown: "Delete users"
},
{
name: "Delete users",
testMatch: /global-teardown\.ts/
},
{
name: "Chromium",
use: { ...devices["Desktop Chrome"] }
use: { ...devices["Desktop Chrome"] },
dependencies: ["Register users"]
},
{
name: "Firefox",
use: { ...devices["Desktop Firefox"] },
dependencies: ["Register users"]
}
/*
Upstream bug "Error: browserContext.newPage: Target page, context or browser has been closed"
{
name: "Webkit",
use: { ...devices["Desktop Safari"] },
dependencies: ["Register users"]
} */
]
};

View File

@@ -1,5 +1,6 @@
import { redirect } from "@sveltejs/kit";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { User } from "$lib/db-schema";
export const handle = async ({ event, resolve }) => {
if (!event.url.pathname.startsWith("/api/")) {
@@ -20,6 +21,13 @@ export const handle = async ({ event, resolve }) => {
throw redirect(303, "/");
}
if (
(userData.data as User).user_role !== "administrator" &&
event.url.pathname.includes("/manage")
) {
throw redirect(303, "/");
}
event.locals.user = userData.data;
}
}

View File

@@ -25,7 +25,7 @@
margin-inline-start: -2rem;
border-radius: 50%;
border: var(--border-primary);
border-width: 0.125rem;
border-width: 0.25rem;
border-block-start-color: var(--color-accent);
animation: spinner 500ms linear infinite;
}

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { deserialize, applyAction } from "$app/forms";
import { textareaScrollTop, previewContent } from "$lib/runes.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { LOADING_DELAY } from "$lib/utils";
const {
apiPrefix,
@@ -10,6 +12,8 @@
}: { apiPrefix: string; label: string; name: string; content: string } = $props();
let mainContentTextarea: HTMLTextAreaElement;
let loadingDelay: number;
let pasting = $state(false);
const updateScrollPercentage = () => {
const { scrollTop, scrollHeight, clientHeight } = mainContentTextarea;
@@ -28,6 +32,8 @@
if (!fileObject) return;
loadingDelay = window.setTimeout(() => (pasting = true), LOADING_DELAY);
const formData = new FormData();
formData.append("file", fileObject);
@@ -46,23 +52,37 @@
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
const target = event.target as HTMLTextAreaElement;
const markdownToInsert = `![](${fileUrl})`;
const cursorPosition = target.selectionStart;
const newContent =
target.value.slice(0, target.selectionStart) +
`![](${fileUrl})` +
target.value.slice(target.selectionStart);
target.value.slice(0, cursorPosition) +
markdownToInsert +
target.value.slice(cursorPosition);
target.value = newContent;
previewContent.value = newContent;
} else {
return;
const newCursorPosition = cursorPosition + markdownToInsert.length;
target.setSelectionRange(newCursorPosition, newCursorPosition);
target.focus();
}
window.clearTimeout(loadingDelay);
pasting = false;
return;
};
</script>
{#if pasting}
<LoadingSpinner />
{/if}
<label>
{label}:
<textarea
{name}
rows="20"
maxlength="200000"
bind:value={previewContent.value}
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}

View File

@@ -51,8 +51,7 @@
background-color: var(--bg-primary);
border-radius: var(--border-radius);
border: var(--border-primary);
inline-size: var(--modal-width);
max-inline-size: 100%;
inline-size: min(var(--modal-width), 100%);
max-block-size: calc(100vh - var(--space-m));
overflow-y: auto;
z-index: 20;

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { page } from "$app/stores";
import { PAGINATION_MAX_ITEMS } from "$lib/utils";
const { commonFilters = [], resultCount }: { commonFilters?: string[]; resultCount: number } =
$props();
</script>
<div class="pagination">
{#snippet commonFilterInputs()}
{#each commonFilters as filter}
<input type="hidden" name={filter} value={$page.url.searchParams.get(filter)} />
{/each}
{/snippet}
<p>
{$page.url.searchParams.get("page") ?? 1} / {Math.max(
Math.ceil(resultCount / PAGINATION_MAX_ITEMS),
1
)}
</p>
<form method="GET">
<input type="hidden" name="page" value={1} />
{@render commonFilterInputs()}
<button type="submit" disabled={($page.url.searchParams.get("page") ?? "1") === "1"}
>First</button
>
</form>
<form method="GET">
<input
type="hidden"
name="page"
value={Math.max(1, Number.parseInt($page.url.searchParams.get("page") ?? "1") - 1)}
/>
{@render commonFilterInputs()}
<button type="submit" disabled={($page.url.searchParams.get("page") ?? "1") === "1"}
>Previous</button
>
</form>
<form method="GET">
<input
type="hidden"
name="page"
value={Math.min(
Math.max(Math.ceil(resultCount / PAGINATION_MAX_ITEMS), 1),
Number.parseInt($page.url.searchParams.get("page") ?? "1") + 1
)}
/>
{@render commonFilterInputs()}
<button
type="submit"
disabled={($page.url.searchParams.get("page") ?? "1") ===
Math.max(Math.ceil(resultCount / PAGINATION_MAX_ITEMS), 1).toString()}>Next</button
>
</form>
<form method="GET">
<input
type="hidden"
name="page"
value={Math.max(Math.ceil(resultCount / PAGINATION_MAX_ITEMS), 1)}
/>
{@render commonFilterInputs()}
<button
type="submit"
disabled={($page.url.searchParams.get("page") ?? "1") ===
Math.max(Math.ceil(resultCount / PAGINATION_MAX_ITEMS), 1).toString()}>Last</button
>
</form>
</div>
<style>
.pagination {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
justify-content: end;
}
.pagination > form:first-of-type {
margin-inline-start: auto;
}
</style>

View File

@@ -3,11 +3,39 @@
</script>
{#if success}
<p class="toast success">{message}</p>
<p class="toast success">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width="20"
height="20"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
></path>
</svg>
{message}
</p>
{/if}
{#if success === false}
<p class="toast error">{message}</p>
<p class="toast error">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width="20"
height="20"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
></path>
</svg>
{message}
</p>
{/if}
<style>

View File

@@ -24,6 +24,16 @@
const scrollHeight = previewElement.scrollHeight - previewElement.clientHeight;
previewElement.scrollTop = (textareaScrollTop.value / 100) * scrollHeight;
});
const tabs = [
"settings",
"articles",
"categories",
"collaborators",
"legal-information",
"publish",
"logs"
];
</script>
<input type="checkbox" id="toggle-mobile-preview" hidden />
@@ -34,27 +44,17 @@
<nav class="operations__nav">
<ul class="unpadded">
{#each tabs.filter((tab) => (tab !== "categories" && contentType === "Blog") || contentType === "Docs") as tab}
<li>
<a href="/website/{id}">Settings</a>
</li>
<li>
<a href="/website/{id}/articles">Articles</a>
</li>
{#if contentType === "Docs"}
<a href="/website/{id}/categories">Categories</a>
{/if}
<li>
<a href="/website/{id}/collaborators">Collaborators</a>
</li>
<li>
<a href="/website/{id}/legal-information">Legal information</a>
</li>
<li>
<a href="/website/{id}/publish">Publish</a>
</li>
<li>
<a href="/website/{id}/logs">Logs</a>
<a
href="/website/{id}{tab === 'settings' ? '' : `/${tab}`}"
class:active={tab === "settings"
? $page.url.pathname === `/website/${id}`
: $page.url.pathname.includes(tab)}
>{(tab.charAt(0).toUpperCase() + tab.slice(1)).replace("-", " ") || "Settings"}</a
>
</li>
{/each}
</ul>
</nav>
@@ -117,6 +117,11 @@
gap: var(--space-s);
}
.active {
text-underline-offset: 0.375rem;
text-decoration-thickness: 0.25rem;
}
@media (min-width: 640px) {
label[for="toggle-mobile-preview"] {
display: none;

View File

@@ -27,7 +27,6 @@ export interface Article {
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
title_description_search: any | null;
}
export interface ArticleInput {
id?: string;
@@ -44,7 +43,6 @@ export interface ArticleInput {
created_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
title_description_search?: any | null;
}
const article = {
tableName: "article",
@@ -62,8 +60,7 @@ const article = {
"article_weight",
"created_at",
"last_modified_at",
"last_modified_by",
"title_description_search"
"last_modified_by"
],
requiredForInsert: ["website_id", "title"],
primaryKey: "id",
@@ -302,18 +299,26 @@ const header = {
export interface Home {
website_id: string;
main_content: string;
meta_description: string | null;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface HomeInput {
website_id: string;
main_content: string;
meta_description?: string | null;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const home = {
tableName: "home",
columns: ["website_id", "main_content", "last_modified_at", "last_modified_by"],
columns: [
"website_id",
"main_content",
"meta_description",
"last_modified_at",
"last_modified_by"
],
requiredForInsert: ["website_id", "main_content"],
primaryKey: "website_id",
foreignKeys: {
@@ -433,19 +438,21 @@ export interface User {
id: string;
username: string;
password_hash: string;
role: string;
user_role: string;
max_number_websites: number;
created_at: Date;
}
export interface UserInput {
id?: string;
username: string;
password_hash: string;
role?: string;
user_role?: string;
max_number_websites?: number;
created_at?: Date;
}
const user = {
tableName: "user",
columns: ["id", "username", "password_hash", "role", "created_at"],
columns: ["id", "username", "password_hash", "user_role", "max_number_websites", "created_at"],
requiredForInsert: ["username", "password_hash"],
primaryKey: "id",
foreignKeys: {},
@@ -459,22 +466,22 @@ export interface Website {
user_id: string;
content_type: string;
title: string;
max_storage_size: number;
is_published: boolean;
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
title_search: any | null;
}
export interface WebsiteInput {
id?: string;
user_id?: string;
content_type: string;
title: string;
max_storage_size?: number;
is_published?: boolean;
created_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
title_search?: any | null;
}
const website = {
tableName: "website",
@@ -483,11 +490,11 @@ const website = {
"user_id",
"content_type",
"title",
"max_storage_size",
"is_published",
"created_at",
"last_modified_at",
"last_modified_by",
"title_search"
"last_modified_by"
],
requiredForInsert: ["content_type", "title"],
primaryKey: "id",

View File

@@ -8,8 +8,10 @@
const {
websiteOverview,
article,
apiUrl
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string } = $props();
apiUrl,
websiteUrl
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string; websiteUrl: string } =
$props();
</script>
<Head
@@ -18,6 +20,7 @@
{apiUrl}
title={article.title}
metaDescription={article.meta_description}
{websiteUrl}
/>
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={false} {apiUrl} />

View File

@@ -7,8 +7,20 @@
const {
websiteOverview,
apiUrl,
isLegalPage
}: { websiteOverview: WebsiteOverview; apiUrl: string; isLegalPage: boolean } = $props();
isLegalPage,
websiteUrl
}: {
websiteOverview: WebsiteOverview;
apiUrl: string;
isLegalPage: boolean;
websiteUrl: string;
} = $props();
const sortedArticles = websiteOverview.article.sort((a, b) => {
if (!a.publication_date) return 1;
if (!b.publication_date) return -1;
return new Date(b.publication_date).getTime() - new Date(a.publication_date).getTime();
});
</script>
<Head
@@ -16,9 +28,11 @@
nestingLevel={0}
{apiUrl}
title={isLegalPage ? "Legal information" : websiteOverview.title}
metaDescription={websiteOverview.home.meta_description}
{websiteUrl}
/>
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={true} {apiUrl} />
<Nav {websiteOverview} isDocsTemplate={false} isIndexPage={true} {isLegalPage} {apiUrl} />
<header>
<div class="container">
@@ -41,7 +55,7 @@
</h2>
<ul class="unpadded">
{#each websiteOverview.article as article}
{#each sortedArticles as article}
<li>
{#if article.publication_date}
<p>{article.publication_date}</p>

View File

@@ -1,31 +1,86 @@
<script lang="ts">
import type { WebsiteOverview } from "../../utils";
import { slugify, type WebsiteOverview } from "../../utils";
const {
websiteOverview,
nestingLevel,
apiUrl,
title,
metaDescription
metaDescription,
websiteUrl
}: {
websiteOverview: WebsiteOverview;
nestingLevel: number;
apiUrl: string;
title: string;
metaDescription?: string | null;
websiteUrl: string;
} = $props();
const constructedTitle =
websiteOverview.title === title ? title : `${websiteOverview.title} | ${title}`;
let ogUrl = `${websiteUrl.replace(/\/$/, "")}${nestingLevel === 0 ? (websiteOverview.title === title ? "" : `/${slugify(title)}`) : `/articles/${slugify(title)}`}`;
</script>
<svelte:head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<title>{constructedTitle}</title>
<meta name="description" content={metaDescription ?? title} />
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}styles.css`} />
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}variables.css`} />
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}common.css`} />
<link rel="stylesheet" href={`${"../".repeat(nestingLevel)}scoped.css`} />
{#if websiteOverview.settings.favicon_image}
<link
rel="icon"
href="{apiUrl}/rpc/retrieve_file?id={websiteOverview.settings.favicon_image}"
/>
{/if}
<meta property="og:site_name" content={websiteOverview.title} />
<meta property="og:title" content={constructedTitle} />
<meta property="og:description" content={metaDescription ?? title} />
<meta property="og:type" content="article" />
<meta property="og:url" content={ogUrl} />
{#if websiteOverview.header.logo_image}
<meta
property="og:image"
content="{apiUrl}/rpc/retrieve_file?id={websiteOverview.header.logo_image}"
/>
{/if}
<script>
const determineTheme = (skipSetTheme = false) => {
const lightMode = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--display-light");
const darkMode = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--display-dark");
if (!skipSetTheme && lightMode === "none") {
localStorage.setItem("theme", "light");
}
if (!skipSetTheme && darkMode === "none") {
localStorage.setItem("theme", "dark");
}
const currentTheme = localStorage.getItem("theme");
window.addEventListener("DOMContentLoaded", (event) => {
const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
document.querySelector("#toggle-theme").checked =
darkMode === "none" ? currentTheme === "light" : currentTheme === "dark";
});
};
determineTheme(true);
window.addEventListener("DOMContentLoaded", (event) => {
document.querySelector('label[for="toggle-theme"]').addEventListener("click", () => {
determineTheme();
});
});
</script>
</svelte:head>

View File

@@ -6,12 +6,14 @@
websiteOverview,
isDocsTemplate,
isIndexPage,
apiUrl
apiUrl,
isLegalPage
}: {
websiteOverview: WebsiteOverview;
isDocsTemplate: boolean;
isIndexPage: boolean;
apiUrl: string;
isLegalPage?: boolean;
} = $props();
const categorizedArticles = Object.fromEntries(
@@ -70,7 +72,10 @@
</ul>
</section>
{/if}
<a href={isIndexPage ? "." : ".."}>
<svelte:element
this={isIndexPage && !isLegalPage ? "span" : "a"}
href={`${isLegalPage ? "./" : "../"}`}
>
{#if websiteOverview.header.logo_type === "text"}
<strong>{websiteOverview.header.logo_text}</strong>
{:else}
@@ -81,6 +86,33 @@
alt=""
/>
{/if}
</a>
</svelte:element>
<label style="margin-inline-start: auto;" for="toggle-theme">
<input type="checkbox" id="toggle-theme" hidden />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width="20"
height="20"
>
<path
d="M10 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 2ZM10 15a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 15ZM10 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM15.657 5.404a.75.75 0 1 0-1.06-1.06l-1.061 1.06a.75.75 0 0 0 1.06 1.06l1.06-1.06ZM6.464 14.596a.75.75 0 1 0-1.06-1.06l-1.06 1.06a.75.75 0 0 0 1.06 1.06l1.06-1.06ZM18 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 18 10ZM5 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 5 10ZM14.596 15.657a.75.75 0 0 0 1.06-1.06l-1.06-1.061a.75.75 0 1 0-1.06 1.06l1.06 1.06ZM5.404 6.464a.75.75 0 0 0 1.06-1.06l-1.06-1.06a.75.75 0 1 0-1.061 1.06l1.06 1.06Z"
></path>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width="20"
height="20"
>
<path
fill-rule="evenodd"
d="M7.455 2.004a.75.75 0 0 1 .26.77 7 7 0 0 0 9.958 7.967.75.75 0 0 1 1.067.853A8.5 8.5 0 1 1 6.647 1.921a.75.75 0 0 1 .808.083Z"
clip-rule="evenodd"
></path>
</svg>
</label>
</div>
</nav>

View File

@@ -8,8 +8,10 @@
const {
websiteOverview,
article,
apiUrl
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string } = $props();
apiUrl,
websiteUrl
}: { websiteOverview: WebsiteOverview; article: Article; apiUrl: string; websiteUrl: string } =
$props();
</script>
<Head
@@ -18,6 +20,7 @@
{apiUrl}
title={article.title}
metaDescription={article.meta_description}
{websiteUrl}
/>
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={false} {apiUrl} />

View File

@@ -7,8 +7,14 @@
const {
websiteOverview,
apiUrl,
isLegalPage
}: { websiteOverview: WebsiteOverview; apiUrl: string; isLegalPage: boolean } = $props();
isLegalPage,
websiteUrl
}: {
websiteOverview: WebsiteOverview;
apiUrl: string;
isLegalPage: boolean;
websiteUrl: string;
} = $props();
</script>
<Head
@@ -16,9 +22,11 @@
nestingLevel={0}
{apiUrl}
title={isLegalPage ? "Legal information" : websiteOverview.title}
metaDescription={websiteOverview.home.meta_description}
{websiteUrl}
/>
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={true} {apiUrl} />
<Nav {websiteOverview} isDocsTemplate={true} isIndexPage={true} {isLegalPage} {apiUrl} />
<header>
<div class="container">

View File

@@ -3,7 +3,6 @@ import type { Renderer, Token } from "marked";
import { markedHighlight } from "marked-highlight";
import hljs from "highlight.js";
import DOMPurify from "isomorphic-dompurify";
import { applyAction, deserialize } from "$app/forms";
import type {
Website,
Settings,
@@ -148,12 +147,18 @@ const createMarkdownParser = (showToc = true) => {
export const md = (markdownContent: string, showToc = true) => {
const marked = createMarkdownParser(showToc);
const html = DOMPurify.sanitize(marked.parse(markdownContent) as string);
let html = "";
try {
html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false }));
} catch (error) {
html = JSON.stringify(error);
}
return html;
};
export const LOADING_DELAY = 500;
export const LOADING_DELAY = 250;
let loadingDelay: number;
export const enhanceForm = (options?: {
@@ -174,6 +179,8 @@ export const enhanceForm = (options?: {
};
};
export const PAGINATION_MAX_ITEMS = 20;
export const hexToHSL = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;

View File

@@ -18,7 +18,7 @@ export const actions: Actions = {
return response;
}
cookies.set("session_token", response.data.token, { path: "/" });
cookies.set("session_token", response.data.token, { path: "/", maxAge: 86400 });
return response;
}
};

View File

@@ -25,5 +25,5 @@
<input type="password" name="password" required />
</label>
<button type="submit">Submit</button>
<button type="submit">Login</button>
</form>

View File

@@ -31,27 +31,68 @@
clip-rule="evenodd"
></path>
</svg>
Account registration is disabled on this instance
Registration is disabled
</p>
{:else}
<div class="registration-wrapper">
<form method="POST" use:enhance={enhanceForm()}>
<label>
Username:
<input type="text" name="username" minlength="3" maxlength="16" required />
<input
type="text"
name="username"
minlength="3"
maxlength="16"
pattern="^[a-zA-Z0-9_\-]+$"
required
/>
</label>
<label>
Password:
<input type="password" name="password" minlength="12" maxlength="128" required />
</label>
<button type="submit">Submit</button>
<button type="submit">Register</button>
</form>
<details>
<summary>Password requirements</summary>
<ul>
<li>Must be between 12 and 128 characters long</li>
<li>Must contain at least one lowercase letter</li>
<li>Must contain at least one uppercase letter</li>
<li>Must contain at least one number</li>
<li>Must contain at least one special character</li>
</ul>
</details>
</div>
{/if}
<style>
.registration-disabled {
display: flex;
gap: 0.5rem;
gap: var(--space-2xs);
align-items: center;
}
.registration-wrapper {
display: flex;
flex-wrap: wrap;
gap: var(--space-l);
}
.registration-wrapper > form {
inline-size: 30ch;
flex-grow: 1;
}
.registration-wrapper > details {
inline-size: 35ch;
}
@media (max-width: 700px) {
.registration-wrapper > form {
order: 1;
}
}
</style>

View File

@@ -1,20 +1,18 @@
import type { Actions, PageServerLoad } from "./$types";
import { apiRequest } from "$lib/server/utils";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import type { Website } from "$lib/db-schema";
import type { Collab, Website } from "$lib/db-schema";
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const searchQuery = url.searchParams.get("website_search_query");
const filterBy = url.searchParams.get("website_filter");
const searchQuery = url.searchParams.get("query");
const filterBy = url.searchParams.get("filter");
const params = new URLSearchParams();
const baseFetchUrl = `${API_BASE_PREFIX}/website?order=last_modified_at.desc,created_at.desc`;
const baseFetchUrl = `${API_BASE_PREFIX}/website?select=*,collab(user_id,permission_level)&collab.user_id=eq.${locals.user.id}&or=(user_id.eq.${locals.user.id},collab.not.is.null)&order=last_modified_at.desc,created_at.desc`;
if (searchQuery) {
params.append("title_search", `wfts(english).${searchQuery}`);
params.append("title", `wfts.${searchQuery}`);
}
switch (filterBy) {
@@ -39,7 +37,7 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => {
totalWebsites.data.headers.get("content-range")?.split("/").at(-1)
);
const websites: Website[] = (
const websites: (Website & { collab: Collab[] })[] = (
await apiRequest(fetch, constructedFetchUrl, "GET", {
returnData: true
})
@@ -47,7 +45,8 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => {
return {
totalWebsiteCount,
websites
websites,
user: locals.user
};
};
@@ -77,15 +76,6 @@ export const actions: Actions = {
const data = await request.formData();
const id = data.get("id");
const oldDomainPrefix = (
await apiRequest(fetch, `${API_BASE_PREFIX}/domain_prefix?website_id=eq.${id}`, "GET", {
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
})
).data;
const deleteWebsite = await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${id}`,
@@ -99,16 +89,6 @@ export const actions: Actions = {
return deleteWebsite;
}
await rm(join("/", "var", "www", "archtika-websites", "previews", id as string), {
recursive: true,
force: true
});
await rm(join("/", "var", "www", "archtika-websites", oldDomainPrefix?.prefix ?? id), {
recursive: true,
force: true
});
return deleteWebsite;
}
};

View File

@@ -39,7 +39,7 @@
<input type="text" name="title" maxlength="50" pattern="\S(.*\S)?" required />
</label>
<button type="submit">Submit</button>
<button type="submit">Create website</button>
</form>
</Modal>
</section>
@@ -55,36 +55,29 @@
<form method="GET">
<label>
Search:
<input
type="text"
name="website_search_query"
value={$page.url.searchParams.get("website_search_query")}
/>
<input type="text" name="query" value={$page.url.searchParams.get("query")} />
</label>
<label>
Filter:
<select name="website_filter">
<option value="all" selected={"all" === $page.url.searchParams.get("website_filter")}
<select name="filter">
<option value="all" selected={"all" === $page.url.searchParams.get("filter")}
>Show all</option
>
<option
value="creations"
selected={"creations" === $page.url.searchParams.get("website_filter")}
>Created by you</option
selected={"creations" === $page.url.searchParams.get("filter")}>Created by you</option
>
<option
value="shared"
selected={"shared" === $page.url.searchParams.get("website_filter")}
<option value="shared" selected={"shared" === $page.url.searchParams.get("filter")}
>Shared with you</option
>
</select>
</label>
<button type="submit">Submit</button>
<button type="submit">Apply</button>
</form>
</details>
<ul class="website-grid unpadded">
{#each data.websites as { id, content_type, title, created_at } (id)}
{#each data.websites as { id, user_id, content_type, title, created_at, collab } (id)}
<li class="website-card">
<p>
<strong>
@@ -112,7 +105,7 @@
>
<input type="hidden" name="id" value={id} />
<label>
Title
Title:
<input
type="text"
name="title"
@@ -123,7 +116,11 @@
/>
</label>
<button type="submit">Submit</button>
<button
type="submit"
disabled={data.user.id !== user_id && collab[0].permission_level !== 30}
>Update website</button
>
</form>
</Modal>
<Modal id="delete-website-{id}" text="Delete">
@@ -140,7 +137,7 @@
>
<input type="hidden" name="id" value={id} />
<button type="submit">Delete website</button>
<button type="submit" disabled={data.user.id !== user_id}>Delete website</button>
</form>
</Modal>
</div>

View File

@@ -1,9 +1,19 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ fetch, locals }) => {
const storageSizes = await apiRequest(
fetch,
`${API_BASE_PREFIX}/rpc/user_websites_storage_size`,
"GET",
{
returnData: true
}
);
return {
user: locals.user
user: locals.user,
storageSizes
};
};
@@ -11,7 +21,7 @@ export const actions: Actions = {
logout: async ({ cookies }) => {
cookies.delete("session_token", { path: "/" });
return { success: true, message: "Successfully logged out" };
return { success: true, message: "Successfully logged out, you can refresh the page" };
},
deleteAccount: async ({ request, fetch, cookies }) => {
const data = await request.formData();

View File

@@ -33,6 +33,30 @@
</ul>
</section>
{#if (data.storageSizes.data ?? []).length > 0}
<section id="storage">
<h2>
<a href="#storage">Storage</a>
</h2>
<ul class="unpadded storage-grid">
{#each data.storageSizes.data as { website_title, storage_size_bytes, max_storage_bytes, max_storage_pretty, diff_storage_pretty }}
<li>
<strong>{website_title}</strong>
<label>
{max_storage_pretty} total &mdash; {diff_storage_pretty} free<br />
<meter
value={storage_size_bytes}
min="0"
max={max_storage_bytes}
high={max_storage_bytes * 0.75}
></meter>
</label>
</li>
{/each}
</ul>
</section>
{/if}
<section id="logout">
<h2>
<a href="#logout">Logout</a>
@@ -71,4 +95,17 @@
form[action="?/logout"] > button {
max-inline-size: fit-content;
}
.storage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 35ch), 1fr));
row-gap: var(--space-s);
column-gap: var(--space-m);
}
.storage-grid > li {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
</style>

View File

@@ -0,0 +1,80 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { Website, User } from "$lib/db-schema";
import { PAGINATION_MAX_ITEMS } from "$lib/utils";
export const load: PageServerLoad = async ({ fetch, url }) => {
const currentPage = Number.parseInt(url.searchParams.get("page") ?? "1");
const resultOffset = (currentPage - 1) * PAGINATION_MAX_ITEMS;
const usersWithWebsites: (User & { website: Website[] })[] = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/user?select=*,website!user_id(*)&order=created_at&limit=${PAGINATION_MAX_ITEMS}&offset=${resultOffset}`,
"GET",
{
returnData: true
}
)
).data;
const resultUsers = await apiRequest(fetch, `${API_BASE_PREFIX}/user`, "HEAD", {
headers: {
Prefer: "count=exact"
},
returnData: true
});
const resultUsersCount = Number(resultUsers.data.headers.get("content-range")?.split("/").at(-1));
return {
usersWithWebsites,
API_BASE_PREFIX,
resultUsersCount
};
};
export const actions: Actions = {
updateMaxWebsiteAmount: async ({ request, fetch }) => {
const data = await request.formData();
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/user?id=eq.${data.get("user-id")}`,
"PATCH",
{
body: {
max_number_websites: data.get("number-of-websites")
},
successMessage: "Successfully updated user website limit"
}
);
},
updateStorageLimit: async ({ request, fetch }) => {
const data = await request.formData();
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`,
"PATCH",
{
body: {
max_storage_size: data.get("storage-size")
},
successMessage: "Successfully updated user website storage size"
}
);
},
deleteUser: async ({ request, fetch }) => {
const data = await request.formData();
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/user?id=eq.${data.get("user-id")}`,
"DELETE",
{
successMessage: "Successfully deleted user"
}
);
}
};

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Modal from "$lib/components/Modal.svelte";
import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import type { ActionData, PageServerData } from "./$types";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
import DateTime from "$lib/components/DateTime.svelte";
import Pagination from "$lib/components/Pagination.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props();
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending.value}
<LoadingSpinner />
{/if}
<section id="all-users">
<hgroup>
<h2>
<a href="#all-users">All users</a>
</h2>
<p>
<strong>{data.resultUsersCount.toLocaleString("en", { useGrouping: true })}</strong>
<small>result(s)</small>
</p>
</hgroup>
<div class="scroll-container">
<table>
<thead>
<tr>
<th>Account creation</th>
<th>UUID</th>
<th>Username</th>
<th>Manage</th>
</tr>
</thead>
<tbody>
{#each data.usersWithWebsites as { id, created_at, username, max_number_websites, website }}
<tr>
<td>
<DateTime date={created_at} />
</td>
<td>{id}</td>
<td>{username}</td>
<td>
<Modal id="manage-user-{id}" text="Manage">
<hgroup>
<h3>Manage user</h3>
<p>User "{username}"</p>
</hgroup>
<form
method="POST"
action="?/updateMaxWebsiteAmount"
use:enhance={enhanceForm({ reset: false })}
>
<input type="hidden" name="user-id" value={id} />
<label>
Number of websites allowed:
<input
type="number"
name="number-of-websites"
min="0"
value={max_number_websites}
/>
</label>
<button type="submit">Update website limit</button>
</form>
{#if website.length > 0}
<h4>Websites</h4>
{#each website as { id, title, max_storage_size }}
<details>
<summary>{title}</summary>
<div>
<form
method="POST"
action="?/updateStorageLimit"
use:enhance={enhanceForm({ reset: false })}
>
<input type="hidden" name="website-id" value={id} />
<label>
Storage limit in MB:
<input
type="number"
name="storage-size"
min="0"
value={max_storage_size}
/>
</label>
<button type="submit">Update storage limit</button>
</form>
</div>
</details>
{/each}
{/if}
<h4>Delete user</h4>
<details>
<summary>Delete</summary>
<div>
<p>
<strong>Caution!</strong>
Deleting the user will irretrievably erase all their data.
</p>
<form
method="POST"
action="?/deleteUser"
use:enhance={enhanceForm({ closeModal: true })}
>
<input type="hidden" name="user-id" value={id} />
<button type="submit">Delete user</button>
</form>
</div>
</details>
</Modal>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination resultCount={data.resultUsersCount} />
</section>
<style>
form[action="?/deleteUser"] {
margin-block-start: var(--space-2xs);
}
</style>

View File

@@ -3,7 +3,7 @@ import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import { error } from "@sveltejs/kit";
import type { Website, Home, User } from "$lib/db-schema";
export const load: LayoutServerLoad = async ({ params, fetch }) => {
export const load: LayoutServerLoad = async ({ locals, params, fetch }) => {
const websiteData = await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,user!user_id(username)`,
@@ -31,8 +31,27 @@ export const load: LayoutServerLoad = async ({ params, fetch }) => {
})
).data;
let permissionLevel = 40;
if (website.user_id !== locals.user.id) {
permissionLevel = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/collab?select=permission_level&website_id=eq.${params.websiteId}&user_id=eq.${locals.user.id}`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
)
).data.permission_level;
}
return {
website,
home
home,
permissionLevel
};
};

View File

@@ -4,37 +4,24 @@ import { apiRequest } from "$lib/server/utils";
import type { Settings, Header, Footer } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch }) => {
const globalSettings: Settings = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
const [globalSettingsResponse, headerResponse, footerResponse] = await Promise.all([
apiRequest(fetch, `${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`, "GET", {
headers: { Accept: "application/vnd.pgrst.object+json" },
returnData: true
}
)
).data;
const header: Header = (
await apiRequest(fetch, `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, "GET", {
headers: {
Accept: "application/vnd.pgrst.object+json"
},
}),
apiRequest(fetch, `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, "GET", {
headers: { Accept: "application/vnd.pgrst.object+json" },
returnData: true
}),
apiRequest(fetch, `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, "GET", {
headers: { Accept: "application/vnd.pgrst.object+json" },
returnData: true
})
).data;
]);
const footer: Footer = (
await apiRequest(fetch, `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, "GET", {
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
})
).data;
const globalSettings: Settings = globalSettingsResponse.data;
const header: Header = headerResponse.data;
const footer: Footer = footerResponse.data;
return {
globalSettings,
@@ -56,7 +43,6 @@ export const actions: Actions = {
};
if (faviconFile) {
headers["X-Mimetype"] = faviconFile.type;
headers["X-Original-Filename"] = faviconFile.name;
}
@@ -97,7 +83,6 @@ export const actions: Actions = {
};
if (logoImage) {
headers["X-Mimetype"] = logoImage.type;
headers["X-Original-Filename"] = logoImage.name;
}
@@ -134,7 +119,8 @@ export const actions: Actions = {
"PATCH",
{
body: {
main_content: data.get("main-content")
main_content: data.get("main-content"),
meta_description: data.get("description")
},
successMessage: "Successfully updated home"
}
@@ -164,7 +150,6 @@ export const actions: Actions = {
"Content-Type": "application/octet-stream",
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId,
"X-Mimetype": file.type,
"X-Original-Filename": file.name
},
body: await file.arrayBuffer(),

View File

@@ -10,7 +10,6 @@
import { sending } from "$lib/runes.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
import { previewContent } from "$lib/runes.svelte";
const { data, form }: { data: PageServerData & LayoutServerData; form: ActionData } = $props();
previewContent.value = data.home.main_content;
@@ -83,7 +82,11 @@
<input type="file" name="favicon" accept={ALLOWED_MIME_TYPES.join(", ")} />
</label>
{#if data.globalSettings.favicon_image}
<Modal id="preview-favicon-global-{data.globalSettings.website_id}" text="Preview">
<Modal
id="preview-favicon-global-{data.globalSettings.website_id}"
text="Preview"
isWider={true}
>
<img
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.globalSettings.favicon_image}`}
alt=""
@@ -92,7 +95,7 @@
{/if}
</div>
<button type="submit">Submit</button>
<button type="submit" disabled={data.permissionLevel === 10}>Update global</button>
</form>
</section>
@@ -130,7 +133,7 @@
<input type="file" name="logo-image" accept={ALLOWED_MIME_TYPES.join(", ")} />
</label>
{#if data.header.logo_image}
<Modal id="preview-logo-header-{data.header.website_id}" text="Preview">
<Modal id="preview-logo-header-{data.header.website_id}" text="Preview" isWider={true}>
<img
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.header.logo_image}`}
alt=""
@@ -139,7 +142,7 @@
{/if}
</div>
<button type="submit">Submit</button>
<button type="submit" disabled={data.permissionLevel === 10}>Update header</button>
</form>
</section>
@@ -149,6 +152,12 @@
</h2>
<form action="?/updateHome" method="POST" use:enhance={enhanceForm({ reset: false })}>
<label>
Description:
<textarea name="description" rows="5" maxlength="250" required
>{data.home.meta_description}</textarea
>
</label>
<MarkdownEditor
apiPrefix={data.API_BASE_PREFIX}
label="Main content"
@@ -156,7 +165,7 @@
content={data.home.main_content}
/>
<button type="submit">Submit</button>
<button type="submit" disabled={data.permissionLevel === 10}>Update home</button>
</form>
</section>
@@ -173,7 +182,7 @@
>
</label>
<button type="submit">Submit</button>
<button type="submit" disabled={data.permissionLevel === 10}>Update footer</button>
</form>
</section>
</WebsiteEditor>

View File

@@ -4,12 +4,12 @@ import { apiRequest } from "$lib/server/utils";
import type { Article, DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch, url, parent, locals }) => {
const searchQuery = url.searchParams.get("article_search_query");
const filterBy = url.searchParams.get("article_filter");
const searchQuery = url.searchParams.get("query");
const filterBy = url.searchParams.get("filter");
const { website, home } = await parent();
const { website, home, permissionLevel } = await parent();
let baseFetchUrl = `${API_BASE_PREFIX}/article?website_id=eq.${params.websiteId}&select=id,title`;
let baseFetchUrl = `${API_BASE_PREFIX}/article?website_id=eq.${params.websiteId}&select=id,user_id,title`;
if (website.content_type === "Docs") {
baseFetchUrl +=
",article_weight,docs_category(category_name,category_weight)&order=docs_category(category_weight).desc.nullslast,article_weight.desc.nullslast";
@@ -21,7 +21,7 @@ export const load: PageServerLoad = async ({ params, fetch, url, parent, locals
const parameters = new URLSearchParams();
if (searchQuery) {
parameters.append("title_description_search", `wfts(english).${searchQuery}`);
parameters.append("title", `wfts.${searchQuery}`);
}
switch (filterBy) {
@@ -56,7 +56,9 @@ export const load: PageServerLoad = async ({ params, fetch, url, parent, locals
totalArticleCount,
articles,
website,
home
home,
permissionLevel,
user: locals.user
};
};

View File

@@ -40,7 +40,7 @@
<input type="text" name="title" pattern="\S(.*\S)?" maxlength="100" required />
</label>
<button type="submit">Submit</button>
<button type="submit" disabled={data.permissionLevel === 10}>Create article</button>
</form>
</Modal>
</section>
@@ -56,36 +56,30 @@
<form method="GET">
<label>
Search:
<input
type="text"
name="article_search_query"
value={$page.url.searchParams.get("article_search_query")}
/>
<input type="text" name="query" value={$page.url.searchParams.get("query")} />
</label>
<label>
Filter:
<select name="article_filter">
<option value="all" selected={"all" === $page.url.searchParams.get("article_filter")}
<select name="filter">
<option value="all" selected={"all" === $page.url.searchParams.get("filter")}
>Show all</option
>
<option
value="creations"
selected={"creations" === $page.url.searchParams.get("article_filter")}
selected={"creations" === $page.url.searchParams.get("filter")}
>Created by you</option
>
<option
value="shared"
selected={"shared" === $page.url.searchParams.get("article_filter")}
<option value="shared" selected={"shared" === $page.url.searchParams.get("filter")}
>Created by others</option
>
</select>
</label>
<button type="submit">Submit</button>
<button type="submit">Apply</button>
</form>
</details>
<ul class="unpadded">
{#each data.articles as { id, title, article_weight, docs_category } (id)}
{#each data.articles as { id, user_id, title, article_weight, docs_category } (id)}
<li class="article-card">
<p>
<strong>{title} {article_weight ? `(${article_weight})` : ""}</strong>
@@ -129,7 +123,12 @@
>
<input type="hidden" name="id" value={id} />
<button type="submit">Delete article</button>
<button
type="submit"
disabled={data.permissionLevel === 10 ||
(data.permissionLevel === 20 && user_id !== data.user.id)}
>Delete article</button
>
</form>
</Modal>
</div>

View File

@@ -23,9 +23,9 @@ export const load: PageServerLoad = async ({ parent, params, fetch }) => {
)
).data;
const { website } = await parent();
const { website, permissionLevel } = await parent();
return { website, article, categories, API_BASE_PREFIX };
return { website, article, categories, API_BASE_PREFIX, permissionLevel };
};
export const actions: Actions = {
@@ -40,7 +40,6 @@ export const actions: Actions = {
};
if (coverFile) {
headers["X-Mimetype"] = coverFile.type;
headers["X-Original-Filename"] = coverFile.name;
}
@@ -82,7 +81,6 @@ export const actions: Actions = {
"Content-Type": "application/octet-stream",
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId,
"X-Mimetype": file.type,
"X-Original-Filename": file.name
},
body: await file.arrayBuffer(),

View File

@@ -44,6 +44,7 @@
<input type="number" name="article-weight" value={data.article.article_weight} min="0" />
</label>
{#if data.categories.length > 0}
<label>
Category:
<select name="category">
@@ -53,6 +54,7 @@
</select>
</label>
{/if}
{/if}
<label>
Title:
@@ -100,7 +102,7 @@
<input type="file" name="cover-image" accept={ALLOWED_MIME_TYPES.join(", ")} />
</label>
{#if data.article.cover_image}
<Modal id="preview-cover-article-{data.article.id}" text="Preview">
<Modal id="preview-cover-article-{data.article.id}" text="Preview" isWider={true}>
<img
src={`${data.API_BASE_PREFIX}/rpc/retrieve_file?id=${data.article.cover_image}`}
alt=""
@@ -117,7 +119,7 @@
content={data.article.main_content ?? ""}
/>
<button type="submit">Submit</button>
<button type="submit" disabled={data.permissionLevel === 10}>Update article</button>
</form>
</section>
</WebsiteEditor>

View File

@@ -2,7 +2,7 @@ import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, fetch }) => {
export const load: PageServerLoad = async ({ parent, params, fetch, locals }) => {
const categories: DocsCategory[] = (
await apiRequest(
fetch,
@@ -14,12 +14,14 @@ export const load: PageServerLoad = async ({ parent, params, fetch }) => {
)
).data;
const { website, home } = await parent();
const { website, home, permissionLevel } = await parent();
return {
categories,
website,
home
home,
permissionLevel,
user: locals.user
};
};

View File

@@ -44,7 +44,7 @@
<input name="category-weight" type="number" min="0" required />
</label>
<button type="submit">Submit</button>
<button type="submit" disabled={data.permissionLevel === 10}>Create category</button>
</form>
</Modal>
</section>
@@ -56,7 +56,7 @@
</h2>
<ul class="unpadded">
{#each data.categories as { id, website_id, category_name, category_weight } (`${website_id}-${id}`)}
{#each data.categories as { id, website_id, user_id, category_name, category_weight } (`${website_id}-${id}`)}
<li class="category-card">
<p>
<strong>{category_name} ({category_weight})</strong>
@@ -89,7 +89,9 @@
<input type="number" name="category-weight" value={category_weight} min="0" />
</label>
<button type="submit">Update category</button>
<button type="submit" disabled={data.permissionLevel === 10}
>Update category</button
>
</form>
</Modal>
<Modal id="delete-category-{id}" text="Delete">
@@ -104,7 +106,12 @@
>
<input type="hidden" name="category-id" value={id} />
<button type="submit">Delete category</button>
<button
type="submit"
disabled={data.permissionLevel === 10 ||
(data.permissionLevel === 20 && user_id !== data.user.id)}
>Delete category</button
>
</form>
</Modal>
</div>

View File

@@ -2,7 +2,7 @@ import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { Collab, User } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, fetch }) => {
export const load: PageServerLoad = async ({ parent, params, fetch, locals }) => {
const collaborators: (Collab & { user: User })[] = (
await apiRequest(
fetch,
@@ -14,12 +14,14 @@ export const load: PageServerLoad = async ({ parent, params, fetch }) => {
)
).data;
const { website, home } = await parent();
const { website, home, permissionLevel } = await parent();
return {
website,
home,
collaborators
collaborators,
permissionLevel,
user: locals.user
};
};

View File

@@ -51,7 +51,9 @@
</select>
</label>
<button type="submit">Submit</button>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Add collaborator</button
>
</form>
</Modal>
</section>
@@ -89,7 +91,11 @@
</select>
</label>
<button type="submit">Update collaborator</button>
<button
type="submit"
disabled={[10, 20].includes(data.permissionLevel) || user_id === data.user.id}
>Update collaborator</button
>
</form>
</Modal>
<Modal id="remove-collaborator-{user_id}" text="Remove">
@@ -104,7 +110,11 @@
>
<input type="hidden" name="user-id" value={user_id} />
<button type="submit">Remove collaborator</button>
<button
type="submit"
disabled={[10, 20].includes(data.permissionLevel) || user_id === data.user.id}
>Remove collaborator</button
>
</form>
</Modal>
</div>

View File

@@ -1,7 +1,5 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import type { LegalInformation } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, fetch, params }) => {
@@ -19,12 +17,13 @@ export const load: PageServerLoad = async ({ parent, fetch, params }) => {
)
).data;
const { website } = await parent();
const { website, permissionLevel } = await parent();
return {
legalInformation,
website,
API_BASE_PREFIX
API_BASE_PREFIX,
permissionLevel
};
};
@@ -58,11 +57,22 @@ export const actions: Actions = {
return deleteLegalInformation;
}
await rm(
join("/", "var", "www", "archtika-websites", params.websiteId, "legal-information.html"),
{ force: true }
);
return deleteLegalInformation;
},
pasteImage: async ({ request, fetch, params }) => {
const data = await request.formData();
const file = data.get("file") as File;
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
headers: {
"Content-Type": "application/octet-stream",
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId,
"X-Original-Filename": file.name
},
body: await file.arrayBuffer(),
successMessage: "Successfully uploaded image",
returnData: true
});
}
};

View File

@@ -61,7 +61,9 @@
content={data.legalInformation?.main_content ?? ""}
/>
<button type="submit">Submit</button>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Update legal information</button
>
</form>
{#if data.legalInformation?.main_content}
@@ -76,7 +78,9 @@
<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>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Delete legal information</button
>
</form>
</Modal>
{/if}

View File

@@ -1,13 +1,15 @@
import type { PageServerLoad } from "./$types";
import type { PageServerLoad, Actions } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { ChangeLog, User, Collab } from "$lib/db-schema";
import DiffMatchPatch from "diff-match-patch";
import { PAGINATION_MAX_ITEMS } from "$lib/utils";
export const load: PageServerLoad = async ({ parent, fetch, params, 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 currentPage = Number.parseInt(url.searchParams.get("logs_results_page") ?? "1");
const resultOffset = (currentPage - 1) * 50;
const userFilter = url.searchParams.get("user");
const resourceFilter = url.searchParams.get("resource");
const operationFilter = url.searchParams.get("operation");
const currentPage = Number.parseInt(url.searchParams.get("page") ?? "1");
const resultOffset = (currentPage - 1) * PAGINATION_MAX_ITEMS;
const searchParams = new URLSearchParams();
@@ -25,10 +27,13 @@ export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
searchParams.append("operation", `eq.${operationFilter.toUpperCase()}`);
}
const constructedFetchUrl = `${baseFetchUrl}&${searchParams.toString()}&limit=50&offset=${resultOffset}`;
const constructedFetchUrl = `${baseFetchUrl}&${searchParams.toString()}&limit=${PAGINATION_MAX_ITEMS}&offset=${resultOffset}`;
const changeLog: (ChangeLog & { user: { username: User["username"] } })[] = (
await apiRequest(fetch, constructedFetchUrl, "GET", { returnData: true })
await apiRequest(fetch, constructedFetchUrl, "GET", {
headers: { Accept: "application/vnd.pgrst.array+json;nulls=stripped" },
returnData: true
})
).data;
const resultChangeLogData = await apiRequest(fetch, constructedFetchUrl, "HEAD", {
@@ -61,3 +66,49 @@ export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
collaborators
};
};
export const actions: Actions = {
computeDiff: async ({ request, fetch }) => {
const data = await request.formData();
const dmp = new DiffMatchPatch();
const htmlDiff = (oldValue: string, newValue: string) => {
const diff = dmp.diff_main(oldValue, newValue);
dmp.diff_cleanupSemantic(diff);
return diff
.map(([op, text]) => {
switch (op) {
case 1:
return `<ins>${text}</ins>`;
case -1:
return `<del>${text}</del>`;
default:
return text;
}
})
.join("");
};
const log: ChangeLog = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/change_log?id=eq.${data.get("id")}&select=old_value,new_value`,
"GET",
{
headers: { Accept: "application/vnd.pgrst.object+json;nulls=stripped" },
returnData: true
}
)
).data;
return {
logId: data.get("id"),
currentDiff: htmlDiff(
JSON.stringify(log.old_value, null, 2),
JSON.stringify(log.new_value, null, 2)
)
};
}
};

View File

@@ -2,62 +2,46 @@
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 type { PageServerData, ActionData } from "./$types";
import { page } from "$app/stores";
import { tables } from "$lib/db-schema";
import { previewContent } from "$lib/runes.svelte";
import { sanitize } from "isomorphic-dompurify";
import DOMPurify from "isomorphic-dompurify";
import { enhanceForm } from "$lib/utils";
import { enhance } from "$app/forms";
import { sending } from "$lib/runes.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import Pagination from "$lib/components/Pagination.svelte";
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("");
};
const { data, form }: { data: PageServerData; form: ActionData } = $props();
let resources = $state({});
if (data.website.content_type === "Blog") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { user, change_log, media, docs_category, ...restTables } = tables;
const { user, change_log, docs_category, ...restTables } = tables;
resources = restTables;
}
if (data.website.content_type === "Docs") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { user, change_log, media, ...restTables } = tables;
const { user, change_log, ...restTables } = tables;
resources = restTables;
}
previewContent.value = data.home.main_content;
let logsSection: HTMLElement;
</script>
{#if sending.value}
<LoadingSpinner />
{/if}
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
>
<section id="logs" bind:this={logsSection}>
<section id="logs">
<hgroup>
<h2>
<a href="#logs">Logs</a>
@@ -74,8 +58,8 @@
Username:
<input
list="users-{data.website.id}"
name="logs_filter_user"
value={$page.url.searchParams.get("logs_filter_user")}
name="user"
value={$page.url.searchParams.get("user")}
/>
<datalist id="users-{data.website.id}">
<option value={data.website.user.username}></option>
@@ -86,40 +70,33 @@
</label>
<label>
Resource:
<select name="logs_filter_resource">
<select name="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
selected={resource === $page.url.searchParams.get("resource")}>{resource}</option
>
{/each}
</select>
</label>
<label>
Operation:
<select name="logs_filter_operation">
<select name="operation">
<option value="all">Show all</option>
<option
value="insert"
selected={"insert" === $page.url.searchParams.get("logs_filter_operation")}
<option value="insert" selected={"insert" === $page.url.searchParams.get("operation")}
>Insert</option
>
<option
value="update"
selected={"update" === $page.url.searchParams.get("logs_filter_operation")}
<option value="update" selected={"update" === $page.url.searchParams.get("operation")}
>Update</option
>
<option
value="delete"
selected={"delete" === $page.url.searchParams.get("logs_filter_operation")}
<option value="delete" selected={"delete" === $page.url.searchParams.get("operation")}
>Delete</option
>
</select>
</label>
<input type="hidden" name="logs_results_page" value={1} />
<button type="submit">Submit</button>
<input type="hidden" name="page" value={1} />
<button type="submit">Apply</button>
</form>
</details>
<div class="scroll-container">
@@ -129,7 +106,7 @@
<th>User</th>
<th>Resource</th>
<th>Operation</th>
<th>Date and time</th>
<th>Date & Time</th>
<th>Changes</th>
</tr>
</thead>
@@ -153,12 +130,32 @@
<hgroup>
<h3>Log changes</h3>
<p>{table_name} &mdash; {operation}</p>
<p>{table_name} &mdash; {operation} &mdash; User "{username}"</p>
</hgroup>
<pre style="white-space: pre-wrap">{@html sanitize(htmlDiff(oldValue, newValue), {
ALLOWED_TAGS: ["ins", "del"]
})}</pre>
{#if old_value && new_value}
<h4>Difference</h4>
<form action="?/computeDiff" method="POST" use:enhance={enhanceForm()}>
<input type="hidden" name="id" value={id} />
<button type="submit">Compute diff</button>
</form>
{#if form?.logId === id && form?.currentDiff}
<pre style="white-space: pre-wrap">{@html DOMPurify.sanitize(
form.currentDiff,
{ ALLOWED_TAGS: ["ins", "del"] }
)}</pre>
{/if}
{/if}
{#if new_value && !old_value}
<h4>New value</h4>
<pre style="white-space: pre-wrap">{DOMPurify.sanitize(newValue)}</pre>
{/if}
{#if old_value && !new_value}
<h4>Old value</h4>
<pre style="white-space: pre-wrap">{DOMPurify.sanitize(oldValue)}</pre>
{/if}
</Modal>
</td>
</tr>
@@ -166,102 +163,9 @@
</tbody>
</table>
</div>
<div class="pagination">
{#snippet commonFilterInputs()}
<input
type="hidden"
name="logs_filter_user"
value={$page.url.searchParams.get("logs_filter_user")}
<Pagination
commonFilters={["user", "resource", "operation"]}
resultCount={data.resultChangeLogCount}
/>
<input
type="hidden"
name="logs_filter_resource"
value={$page.url.searchParams.get("logs_filter_resource")}
/>
<input
type="hidden"
name="logs_filter_operation"
value={$page.url.searchParams.get("logs_filter_operation")}
/>
{/snippet}
<p>
{$page.url.searchParams.get("logs_results_page") ?? 1} / {Math.max(
Math.ceil(data.resultChangeLogCount / 50),
1
)}
</p>
<form method="GET">
<input type="hidden" name="logs_results_page" value={1} />
{@render commonFilterInputs()}
<button
type="submit"
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") === "1"}>First</button
>
</form>
<form method="GET">
<input
type="hidden"
name="logs_results_page"
value={Math.max(
1,
Number.parseInt($page.url.searchParams.get("logs_results_page") ?? "1") - 1
)}
/>
{@render commonFilterInputs()}
<button
type="submit"
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") === "1"}
>Previous</button
>
</form>
<form method="GET">
<input
type="hidden"
name="logs_results_page"
value={Math.min(
Math.max(Math.ceil(data.resultChangeLogCount / 50), 1),
Number.parseInt($page.url.searchParams.get("logs_results_page") ?? "1") + 1
)}
/>
{@render commonFilterInputs()}
<button
type="submit"
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") ===
Math.max(Math.ceil(data.resultChangeLogCount / 50), 1).toString()}>Next</button
>
</form>
<form method="GET">
<input
type="hidden"
name="logs_results_page"
value={Math.max(Math.ceil(data.resultChangeLogCount / 50), 1)}
/>
{@render commonFilterInputs()}
<button
type="submit"
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") ===
Math.max(Math.ceil(data.resultChangeLogCount / 50), 1).toString()}>Last</button
>
</form>
</div>
</section>
</WebsiteEditor>
<style>
.pagination {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
justify-content: end;
}
.pagination > form:first-of-type {
margin-inline-start: auto;
}
button:disabled {
pointer-events: none;
color: hsl(0 0% 50%);
}
</style>

View File

@@ -5,12 +5,12 @@ import BlogIndex from "$lib/templates/blog/BlogIndex.svelte";
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
import DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
import { type WebsiteOverview, hexToHSL, slugify } from "$lib/utils";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename, writeFile, chmod, readdir } from "node:fs/promises";
import { join } from "node:path";
import { render } from "svelte/server";
import type { Actions, PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params, fetch }) => {
export const load: PageServerLoad = async ({ params, fetch, parent }) => {
const websiteOverview: WebsiteOverview = (
await apiRequest(
fetch,
@@ -25,29 +25,15 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
)
).data;
generateStaticFiles(websiteOverview);
const { websitePreviewUrl, websiteProdUrl } = await generateStaticFiles(websiteOverview);
const websitePreviewUrl = `${
dev
? "http://localhost:18000"
: process.env.ORIGIN
? process.env.ORIGIN
: "http://localhost:18000"
}/previews/${websiteOverview.id}/`;
const websiteProdUrl = dev
? `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`
: process.env.ORIGIN
? process.env.ORIGIN.replace(
"//",
`//${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}.`
)
: `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`;
const { permissionLevel } = await parent();
return {
websiteOverview,
websitePreviewUrl,
websiteProdUrl
websiteProdUrl,
permissionLevel
};
};
@@ -67,7 +53,7 @@ export const actions: Actions = {
)
).data;
generateStaticFiles(websiteOverview, false);
await generateStaticFiles(websiteOverview, false);
return await apiRequest(
fetch,
@@ -156,6 +142,23 @@ export const actions: Actions = {
};
const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = true) => {
const websitePreviewUrl = `${
dev
? "http://localhost:18000"
: process.env.ORIGIN
? process.env.ORIGIN
: "http://localhost:18000"
}/previews/${websiteData.id}/`;
const websiteProdUrl = dev
? `http://localhost:18000/${websiteData.domain_prefix?.prefix ?? websiteData.id}/`
: process.env.ORIGIN
? process.env.ORIGIN.replace(
"//",
`//${websiteData.domain_prefix?.prefix ?? websiteData.id}.`
)
: `http://localhost:18000/${websiteData.domain_prefix?.prefix ?? websiteData.id}/`;
const fileContents = (head: string, body: string) => {
return `
<!DOCTYPE html>
@@ -173,7 +176,8 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
props: {
websiteOverview: websiteData,
apiUrl: API_BASE_PREFIX,
isLegalPage: false
isLegalPage: false,
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
}
});
@@ -202,7 +206,8 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
props: {
websiteOverview: websiteData,
article,
apiUrl: API_BASE_PREFIX
apiUrl: API_BASE_PREFIX,
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
}
});
@@ -217,13 +222,17 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
props: {
websiteOverview: websiteData,
apiUrl: API_BASE_PREFIX,
isLegalPage: true
isLegalPage: true,
websiteUrl: isPreview ? websitePreviewUrl : websiteProdUrl
}
});
await writeFile(join(uploadDir, "legal-information.html"), fileContents(head, body));
}
const variableStyles = await readFile(`${process.cwd()}/template-styles/variables.css`, {
encoding: "utf-8"
});
const commonStyles = await readFile(`${process.cwd()}/template-styles/common-styles.css`, {
encoding: "utf-8"
});
@@ -246,22 +255,58 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = tru
} = hexToHSL(websiteData.settings.background_color_light_theme);
await writeFile(
join(uploadDir, "styles.css"),
commonStyles
.concat(specificStyles)
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_H \*\/\s*).*(?=;)/, ` ${hDark}`)
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_S \*\/\s*).*(?=;)/, ` ${sDark}%`)
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_L \*\/\s*).*(?=;)/, ` ${lDark}%`)
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_H \*\/\s*).*(?=;)/, ` ${hLight}`)
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_S \*\/\s*).*(?=;)/, ` ${sLight}%`)
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_L \*\/\s*).*(?=;)/, ` ${lLight}%`)
.replace(
/(?<=\/\* ACCENT_COLOR_DARK_THEME \*\/\s*).*(?=;)/,
` ${websiteData.settings.accent_color_dark_theme}`
join(uploadDir, "variables.css"),
variableStyles
.replaceAll(
/\/\* BACKGROUND_COLOR_DARK_THEME_H \*\/\s*.*?;/g,
`/* BACKGROUND_COLOR_DARK_THEME_H */ ${hDark};`
)
.replace(
/(?<=\/\* ACCENT_COLOR_LIGHT_THEME \*\/\s*).*(?=;)/,
` ${websiteData.settings.accent_color_light_theme}`
.replaceAll(
/\/\* BACKGROUND_COLOR_DARK_THEME_S \*\/\s*.*?;/g,
`/* BACKGROUND_COLOR_DARK_THEME_S */ ${sDark}%;`
)
.replaceAll(
/\/\* BACKGROUND_COLOR_DARK_THEME_L \*\/\s*.*?;/g,
`/* BACKGROUND_COLOR_DARK_THEME_L */ ${lDark}%;`
)
.replaceAll(
/\/\* BACKGROUND_COLOR_LIGHT_THEME_H \*\/\s*.*?;/g,
`/* BACKGROUND_COLOR_LIGHT_THEME_H */ ${hLight};`
)
.replaceAll(
/\/\* BACKGROUND_COLOR_LIGHT_THEME_S \*\/\s*.*?;/g,
`/* BACKGROUND_COLOR_LIGHT_THEME_S */ ${sLight}%;`
)
.replaceAll(
/\/\* BACKGROUND_COLOR_LIGHT_THEME_L \*\/\s*.*?;/g,
`/* BACKGROUND_COLOR_LIGHT_THEME_L */ ${lLight}%;`
)
.replaceAll(
/\/\* ACCENT_COLOR_DARK_THEME \*\/\s*.*?;/g,
`/* ACCENT_COLOR_DARK_THEME */ ${websiteData.settings.accent_color_dark_theme};`
)
.replaceAll(
/\/\* ACCENT_COLOR_LIGHT_THEME \*\/\s*.*?;/g,
`/* ACCENT_COLOR_LIGHT_THEME */ ${websiteData.settings.accent_color_light_theme};`
)
);
await writeFile(join(uploadDir, "common.css"), commonStyles);
await writeFile(join(uploadDir, "scoped.css"), specificStyles);
await setPermissions(isPreview ? join(uploadDir, "../") : uploadDir);
return { websitePreviewUrl, websiteProdUrl };
};
const setPermissions = async (dir: string) => {
await chmod(dir, 0o777);
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await setPermissions(fullPath);
} else {
await chmod(fullPath, 0o777);
}
}
};

View File

@@ -36,7 +36,9 @@
be published on the Internet.
</p>
<form method="POST" action="?/publishWebsite" use:enhance={enhanceForm()}>
<button type="submit">Publish</button>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Publish website</button
>
</form>
</section>
@@ -46,8 +48,7 @@
<a href="#publication-status">Publication status</a>
</h2>
<p>
Your website is published at:
<br />
Your website is published at:<br />
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
</p>
</section>
@@ -74,7 +75,9 @@
required
/>
</label>
<button type="submit">Submit</button>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Update domain prefix</button
>
</form>
{#if data.websiteOverview.domain_prefix?.prefix}
<Modal id="delete-domain-prefix" text="Delete">
@@ -88,7 +91,9 @@
<strong>Caution!</strong>
This action will remove the domain prefix and reset it to its initial value.
</p>
<button type="submit">Delete domain prefix</button>
<button type="submit" disabled={[10, 20].includes(data.permissionLevel)}
>Delete domain prefix</button
>
</form>
</Modal>
{/if}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import "../../template-styles/variables.css";
import "../../template-styles/common-styles.css";
import { page } from "$app/stores";
import type { LayoutServerData } from "./$types";
@@ -20,7 +21,7 @@
let loadingDelay: number;
$effect(() => {
if ($navigating && ["link", "goto"].includes($navigating.type)) {
if ($navigating) {
loadingDelay = window.setTimeout(() => (loading = true), LOADING_DELAY);
} else {
window.clearTimeout(loadingDelay);
@@ -52,6 +53,11 @@
{/if}
<ul class="link-wrapper unpadded">
{#if data.user}
{#if data.user.user_role === "administrator"}
<li>
<a href="/manage">Manage</a>
</li>
{/if}
<li>
<a href="/account">Account</a>
</li>

View File

@@ -13,6 +13,12 @@ nav {
border-block-end: var(--border-primary);
}
nav > .container {
display: flex;
align-items: center;
gap: var(--space-2xs);
}
header > .container {
display: flex;
flex-direction: column;
@@ -33,6 +39,11 @@ footer {
padding-block: var(--space-s);
}
footer {
margin-block-start: auto;
text-align: center;
}
.articles ul {
display: flex;
flex-direction: column;

View File

@@ -1,104 +1,3 @@
@import url("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github.min.css")
screen and (prefers-color-scheme: light);
@import url("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github-dark.min.css")
screen and (prefers-color-scheme: dark);
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-display: swap;
font-weight: 400;
src:
url(https://cdn.jsdelivr.net/fontsource/fonts/jetbrains-mono@latest/latin-400-normal.woff2)
format("woff2"),
url(https://cdn.jsdelivr.net/fontsource/fonts/jetbrains-mono@latest/latin-400-normal.woff)
format("woff");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
:root {
--bg-primary-h: /* BACKGROUND_COLOR_LIGHT_THEME_H */ 0;
--bg-primary-s: /* BACKGROUND_COLOR_LIGHT_THEME_S */ 0%;
--bg-primary-l: /* BACKGROUND_COLOR_LIGHT_THEME_L */ 100%;
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 5%));
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 10%));
--bg-blurred: hsla(
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) - 20%)
);
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 0%);
--color-text-invert: var(--bg-primary);
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 50%));
--color-accent: /* ACCENT_COLOR_LIGHT_THEME */ hsl(210 100% 30%);
--color-success: hsl(105 100% 30%);
--color-error: hsl(0 100% 30%);
--border-primary: 0.0625rem solid var(--color-border);
--border-radius: 0.125rem;
/* Step -1: 14.9953px → 14.2222px */
--font-size--1: clamp(0.8889rem, 0.9592rem + -0.1098cqi, 0.9372rem);
/* Step 0: 16px → 16px */
--font-size-0: clamp(1rem, 1rem + 0cqi, 1rem);
/* Step 1: 17.072px → 18px */
--font-size-1: clamp(1.067rem, 1.0406rem + 0.1318cqi, 1.125rem);
/* Step 2: 18.2158px → 20.25px */
--font-size-2: clamp(1.1385rem, 1.0807rem + 0.2889cqi, 1.2656rem);
/* Step 3: 19.4363px → 22.7813px */
--font-size-3: clamp(1.2148rem, 1.1197rem + 0.4751cqi, 1.4238rem);
/* Step 4: 20.7385px → 25.6289px */
--font-size-4: clamp(1.2962rem, 1.1572rem + 0.6947cqi, 1.6018rem);
/* Step 5: 22.128px → 28.8325px */
--font-size-5: clamp(1.383rem, 1.1925rem + 0.9523cqi, 1.802rem);
/* Space 3xs: 4px → 5px */
--space-3xs: clamp(0.25rem, 0.2336rem + 0.0822cqi, 0.3125rem);
/* Space 2xs: 8px → 10px */
--space-2xs: clamp(0.5rem, 0.4671rem + 0.1645cqi, 0.625rem);
/* Space xs: 12px → 15px */
--space-xs: clamp(0.75rem, 0.7007rem + 0.2467cqi, 0.9375rem);
/* Space s: 16px → 20px */
--space-s: clamp(1rem, 0.9342rem + 0.3289cqi, 1.25rem);
/* Space m: 24px → 30px */
--space-m: clamp(1.5rem, 1.4013rem + 0.4934cqi, 1.875rem);
/* Space l: 32px → 40px */
--space-l: clamp(2rem, 1.8684rem + 0.6579cqi, 2.5rem);
/* Space xl: 48px → 60px */
--space-xl: clamp(3rem, 2.8026rem + 0.9868cqi, 3.75rem);
/* Space 2xl: 64px → 80px */
--space-2xl: clamp(4rem, 3.7368rem + 1.3158cqi, 5rem);
/* Space 3xl: 96px → 120px */
--space-3xl: clamp(6rem, 5.6053rem + 1.9737cqi, 7.5rem);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary-h: /* BACKGROUND_COLOR_DARK_THEME_H */ 0;
--bg-primary-s: /* BACKGROUND_COLOR_DARK_THEME_S */ 0%;
--bg-primary-l: /* BACKGROUND_COLOR_DARK_THEME_L */ 15%;
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 5%));
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 10%));
--bg-blurred: hsla(
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) + 20%)
);
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 100%);
--color-text-invert: var(--bg-primary);
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 50%));
--color-accent: /* ACCENT_COLOR_DARK_THEME */ hsl(210 100% 80%);
--color-success: hsl(105 100% 80%);
--color-error: hsl(0 100% 80%);
color-scheme: dark;
}
}
*,
*::before,
*::after {
@@ -109,11 +8,12 @@
body {
line-height: 1.5;
font-family: system-ui, sans-serif;
font-family: system-ui;
background-color: var(--bg-primary);
display: flex;
flex-direction: column;
min-block-size: 100vh;
color: var(--color-text);
}
button,
@@ -123,6 +23,7 @@ select,
[role="option"],
label[for="toggle-mobile-preview"],
label[for="toggle-sidebar"],
label[for="toggle-theme"],
summary {
cursor: pointer;
}
@@ -131,9 +32,11 @@ input,
button,
textarea,
select,
input[type="file"]::file-selector-button,
a[role="button"],
label[for="toggle-mobile-preview"],
label[for="toggle-sidebar"],
label[for="toggle-theme"],
summary {
font: inherit;
color: inherit;
@@ -157,6 +60,11 @@ input[type="file"] {
inline-size: 100%;
}
input[type="file"]::file-selector-button {
padding-block: calc(var(--space-3xs) / 4);
margin-inline-end: var(--space-2xs);
}
input[type="color"] {
padding: 0;
}
@@ -171,23 +79,45 @@ summary {
}
button,
input[type="file"]::file-selector-button,
a[role="button"],
label[for="toggle-mobile-preview"],
label[for="toggle-sidebar"],
label[for="toggle-theme"],
summary {
background-color: var(--bg-secondary);
}
label:has(svg) {
display: inline-grid;
place-content: center;
}
label[for="toggle-theme"] svg:first-of-type {
display: var(--display-light);
}
label[for="toggle-theme"] svg:last-of-type {
display: var(--display-dark);
}
:is(
button,
a[role="button"],
label[for="toggle-mobile-preview"],
label[for="toggle-sidebar"],
label[for="toggle-theme"],
summary
):hover {
background-color: var(--bg-tertiary);
}
button:disabled {
pointer-events: none;
opacity: 0.5;
z-index: -10;
}
:is(button, input, textarea, select, a, summary, pre):focus,
:is(#toggle-mobile-preview, #toggle-sidebar):checked + label {
outline: 0.125rem solid var(--color-accent);
@@ -303,12 +233,14 @@ pre {
flex-shrink: 0;
}
code {
font-family: "JetBrains Mono", monospace;
code,
kbd {
font-family: monospace;
font-size: var(--font-size--1);
}
:not(pre) > code {
:not(pre) > code,
kbd {
background-color: var(--bg-secondary);
border: var(--border-primary);
padding-inline: var(--space-3xs);
@@ -354,3 +286,105 @@ del {
background-color: var(--color-error);
color: var(--color-text-invert);
}
blockquote {
border-inline-start: var(--border-primary);
border-width: 0.25rem;
padding-inline-start: var(--space-xs);
}
meter {
inline-size: min(512px, 100%);
}
.hljs {
color: var(--hl-color);
background: var(--hl-bg);
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
color: var(--hl-keyword);
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
color: var(--hl-title);
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id,
.hljs-variable {
color: var(--hl-attr);
}
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: var(--hl-string);
}
.hljs-built_in,
.hljs-symbol {
color: var(--hl-built-in);
}
.hljs-code,
.hljs-comment,
.hljs-formula {
color: var(--hl-comment);
}
.hljs-name,
.hljs-quote,
.hljs-selector-pseudo,
.hljs-selector-tag {
color: var(--hl-tag);
}
.hljs-subst {
color: var(--hl-color);
}
.hljs-section {
color: var(--hl-section);
font-weight: bold;
}
.hljs-bullet {
color: var(--hl-bullet);
}
.hljs-emphasis {
color: var(--hl-emphasis);
font-style: italic;
}
.hljs-strong {
color: var(--hl-emphasis);
font-weight: bold;
}
.hljs-addition {
color: var(--hl-addition-text);
background-color: var(--hl-addition-bg);
}
.hljs-deletion {
color: var(--hl-deletion-text);
background-color: var(--hl-deletion-bg);
}

View File

@@ -32,13 +32,13 @@ footer {
padding-block: var(--space-s);
}
section {
scroll-margin-block-start: var(--space-xl);
footer {
margin-block-start: auto;
text-align: center;
}
label[for="toggle-sidebar"] {
display: inline-grid;
place-content: center;
section {
scroll-margin-block-start: var(--space-xl);
}
.docs-navigation {

View File

@@ -0,0 +1,204 @@
html {
--border-primary: 0.0625rem solid var(--color-border);
--border-radius: 0.125rem;
/* Step -1: 14.9953px → 14.2222px */
--font-size--1: clamp(0.8889rem, 0.9592rem + -0.1098cqi, 0.9372rem);
/* Step 0: 16px → 16px */
--font-size-0: clamp(1rem, 1rem + 0cqi, 1rem);
/* Step 1: 17.072px → 18px */
--font-size-1: clamp(1.067rem, 1.0406rem + 0.1318cqi, 1.125rem);
/* Step 2: 18.2158px → 20.25px */
--font-size-2: clamp(1.1385rem, 1.0807rem + 0.2889cqi, 1.2656rem);
/* Step 3: 19.4363px → 22.7813px */
--font-size-3: clamp(1.2148rem, 1.1197rem + 0.4751cqi, 1.4238rem);
/* Step 4: 20.7385px → 25.6289px */
--font-size-4: clamp(1.2962rem, 1.1572rem + 0.6947cqi, 1.6018rem);
/* Step 5: 22.128px → 28.8325px */
--font-size-5: clamp(1.383rem, 1.1925rem + 0.9523cqi, 1.802rem);
/* Space 3xs: 4px → 5px */
--space-3xs: clamp(0.25rem, 0.2336rem + 0.0822cqi, 0.3125rem);
/* Space 2xs: 8px → 10px */
--space-2xs: clamp(0.5rem, 0.4671rem + 0.1645cqi, 0.625rem);
/* Space xs: 12px → 15px */
--space-xs: clamp(0.75rem, 0.7007rem + 0.2467cqi, 0.9375rem);
/* Space s: 16px → 20px */
--space-s: clamp(1rem, 0.9342rem + 0.3289cqi, 1.25rem);
/* Space m: 24px → 30px */
--space-m: clamp(1.5rem, 1.4013rem + 0.4934cqi, 1.875rem);
/* Space l: 32px → 40px */
--space-l: clamp(2rem, 1.8684rem + 0.6579cqi, 2.5rem);
/* Space xl: 48px → 60px */
--space-xl: clamp(3rem, 2.8026rem + 0.9868cqi, 3.75rem);
/* Space 2xl: 64px → 80px */
--space-2xl: clamp(4rem, 3.7368rem + 1.3158cqi, 5rem);
/* Space 3xl: 96px → 120px */
--space-3xl: clamp(6rem, 5.6053rem + 1.9737cqi, 7.5rem);
}
html {
--bg-primary-h: /* BACKGROUND_COLOR_LIGHT_THEME_H */ 0;
--bg-primary-s: /* BACKGROUND_COLOR_LIGHT_THEME_S */ 0%;
--bg-primary-l: /* BACKGROUND_COLOR_LIGHT_THEME_L */ 100%;
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 5%));
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 10%));
--bg-blurred: hsla(
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) - 50%)
);
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 0%);
--color-text-invert: var(--bg-primary);
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 50%));
--color-accent: /* ACCENT_COLOR_LIGHT_THEME */ hsl(210 100% 30%);
--color-success: hsl(105 100% 30%);
--color-error: hsl(0 100% 30%);
--display-light: none;
--display-dark: initial;
--hl-bg: #fff;
--hl-color: #24292e;
--hl-keyword: #d73a49;
--hl-title: #6f42c1;
--hl-attr: #005cc5;
--hl-string: #032f62;
--hl-built-in: #e36209;
--hl-comment: #6a737d;
--hl-tag: #22863a;
--hl-section: #005cc5;
--hl-bullet: #735c0f;
--hl-emphasis: #24292e;
--hl-addition-bg: #f0fff4;
--hl-addition-text: #22863a;
--hl-deletion-bg: #ffeef0;
--hl-deletion-text: #b31d28;
color-scheme: light;
}
html:has(#toggle-theme:checked) {
--bg-primary-h: /* BACKGROUND_COLOR_DARK_THEME_H */ 0;
--bg-primary-s: /* BACKGROUND_COLOR_DARK_THEME_S */ 0%;
--bg-primary-l: /* BACKGROUND_COLOR_DARK_THEME_L */ 15%;
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 5%));
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 10%));
--bg-blurred: hsla(
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) + 50%)
);
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 100%);
--color-text-invert: var(--bg-primary);
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 50%));
--color-accent: /* ACCENT_COLOR_DARK_THEME */ hsl(210 100% 80%);
--color-success: hsl(105 100% 80%);
--color-error: hsl(0 100% 80%);
--display-light: initial;
--display-dark: none;
--hl-bg: #0d1117;
--hl-color: #c9d1d9;
--hl-keyword: #ff7b72;
--hl-title: #d2a8ff;
--hl-attr: #79c0ff;
--hl-string: #a5d6ff;
--hl-built-in: #ffa657;
--hl-comment: #8b949e;
--hl-tag: #7ee787;
--hl-section: #1f6feb;
--hl-bullet: #f2cc60;
--hl-emphasis: #c9d1d9;
--hl-addition-bg: #033a16;
--hl-addition-text: #aff5b4;
--hl-deletion-bg: #67060c;
--hl-deletion-text: #ffdcd7;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
html {
--bg-primary-h: /* BACKGROUND_COLOR_DARK_THEME_H */ 0;
--bg-primary-s: /* BACKGROUND_COLOR_DARK_THEME_S */ 0%;
--bg-primary-l: /* BACKGROUND_COLOR_DARK_THEME_L */ 15%;
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 5%));
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 10%));
--bg-blurred: hsla(
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) + 50%)
);
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 100%);
--color-text-invert: var(--bg-primary);
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 50%));
--color-accent: /* ACCENT_COLOR_DARK_THEME */ hsl(210 100% 80%);
--color-success: hsl(105 100% 80%);
--color-error: hsl(0 100% 80%);
--display-light: initial;
--display-dark: none;
--hl-bg: #0d1117;
--hl-color: #c9d1d9;
--hl-keyword: #ff7b72;
--hl-title: #d2a8ff;
--hl-attr: #79c0ff;
--hl-string: #a5d6ff;
--hl-built-in: #ffa657;
--hl-comment: #8b949e;
--hl-tag: #7ee787;
--hl-section: #1f6feb;
--hl-bullet: #f2cc60;
--hl-emphasis: #c9d1d9;
--hl-addition-bg: #033a16;
--hl-addition-text: #aff5b4;
--hl-deletion-bg: #67060c;
--hl-deletion-text: #ffdcd7;
color-scheme: dark;
}
html:has(#toggle-theme:checked) {
--bg-primary-h: /* BACKGROUND_COLOR_LIGHT_THEME_H */ 0;
--bg-primary-s: /* BACKGROUND_COLOR_LIGHT_THEME_S */ 0%;
--bg-primary-l: /* BACKGROUND_COLOR_LIGHT_THEME_L */ 100%;
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 5%));
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 10%));
--bg-blurred: hsla(
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) - 50%)
);
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 0%);
--color-text-invert: var(--bg-primary);
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 50%));
--color-accent: /* ACCENT_COLOR_LIGHT_THEME */ hsl(210 100% 30%);
--color-success: hsl(105 100% 30%);
--color-error: hsl(0 100% 30%);
--display-light: none;
--display-dark: initial;
--hl-bg: #fff;
--hl-color: #24292e;
--hl-keyword: #d73a49;
--hl-title: #6f42c1;
--hl-attr: #005cc5;
--hl-string: #032f62;
--hl-built-in: #e36209;
--hl-comment: #6a737d;
--hl-tag: #22863a;
--hl-section: #005cc5;
--hl-bullet: #735c0f;
--hl-emphasis: #24292e;
--hl-addition-bg: #f0fff4;
--hl-addition-text: #22863a;
--hl-deletion-bg: #ffeef0;
--hl-deletion-text: #b31d28;
color-scheme: light;
}
}

View File

@@ -1,47 +1,18 @@
import { test as base, expect, type Page } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { test, expect } from "@playwright/test";
import { userOwner, register, authenticate, password } from "./shared";
const username = randomBytes(8).toString("hex");
const password = "T3stuser??!!";
const userDeleted = "test-deleted-a";
const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
await page.goto("/login");
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await use(page);
}
});
test.describe.serial("Account tests", () => {
test("Register", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully registered, you")).toBeVisible();
});
test("Login", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
test("Logout", async ({ authenticatedPage: page }) => {
test(`Logout`, async ({ page }) => {
await authenticate(userOwner, page);
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Logout" }).click();
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
});
test("Delete account", async ({ authenticatedPage: page }) => {
test(`Delete account`, async ({ page }) => {
await register(userDeleted, page);
await authenticate(userDeleted, page);
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
@@ -52,4 +23,3 @@ test.describe.serial("Account tests", () => {
.click();
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
});
});

View File

@@ -0,0 +1,151 @@
import { test, expect } from "@playwright/test";
import {
userOwner,
authenticate,
permissionLevels,
collabUsers,
collabTestingWebsite
} from "./shared";
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Articles" }).click();
});
test(`Create article`, async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click();
await page.locator("#create-article-modal").getByLabel("Title:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
await page
.locator("#create-article-modal")
.getByRole("button", { name: "Create article" })
.click();
await expect(page.getByText("Successfully created article")).toBeVisible();
await expect(page.getByRole("link", { name: "All articles" })).toBeVisible();
});
test.describe("Modify", () => {
test.beforeEach(async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click();
await page.locator("#create-article-modal").getByLabel("Title:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
await page
.locator("#create-article-modal")
.getByRole("button", { name: "Create article" })
.click();
});
test(`Update article`, async ({ page }) => {
await page.getByRole("link", { name: "Edit" }).first().click();
await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("555");
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").press("ControlOrMeta+a");
await page.getByLabel("Title:").fill("Example article");
await page.getByLabel("Description:").click();
await page.getByLabel("Description:").fill("Random description");
await page.getByLabel("Author:").click();
await page.getByLabel("Author:").fill("John Doe");
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Markdown content comes here");
await page.getByRole("button", { name: "Update article" }).click();
await expect(page.getByText("Successfully updated article")).toBeVisible();
});
test(`Delete article`, async ({ page }) => {
await page.getByRole("button", { name: "Delete" }).first().click();
await page.getByRole("button", { name: "Delete article" }).click();
await expect(page.getByText("Successfully deleted article")).toBeVisible();
});
});
});
for (const permissionLevel of permissionLevels) {
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
test.beforeEach(async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Articles" }).click();
});
test(`Create article`, async ({ page }) => {
await page.getByRole("button", { name: "Create article" }).click();
await page.locator("#create-article-modal").getByLabel("Title:").click();
await page.locator("#create-article-modal").getByLabel("Title:").fill("Article");
await page
.locator("#create-article-modal")
.getByRole("button", { name: "Create article" })
.evaluate((node) => node.removeAttribute("disabled"));
await page
.locator("#create-article-modal")
.getByRole("button", { name: "Create article" })
.click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully created article")).toBeVisible();
await expect(page.getByRole("link", { name: "All articles" })).toBeVisible();
}
});
test(`Update article`, async ({ page }) => {
await page.getByRole("link", { name: "Edit" }).first().click();
await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("555");
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").press("ControlOrMeta+a");
await page.getByLabel("Title:").fill("Example article");
await page.getByLabel("Description:").click();
await page.getByLabel("Description:").fill("Random description");
await page.getByLabel("Author:").click();
await page.getByLabel("Author:").fill("John Doe");
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Markdown content comes here");
await page
.getByRole("button", { name: "Update article" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update article" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated article")).toBeVisible();
}
});
test(`Delete article`, async ({ page }) => {
await page.getByRole("button", { name: "Delete" }).first().click();
await page
.getByRole("button", { name: "Delete article" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Delete article" }).click();
switch (permissionLevel) {
case 10:
await expect(page.getByText("Insufficient permissions")).toBeVisible();
break;
case 20:
await expect(
page
.getByText("Successfully deleted article")
.or(page.getByText("Insufficient permissions"))
).toBeVisible();
break;
case 30:
await expect(page.getByText("Successfully deleted article")).toBeVisible();
break;
}
});
});
}

View File

@@ -0,0 +1,149 @@
import { test, expect } from "@playwright/test";
import { randomBytes, randomInt } from "node:crypto";
import {
userOwner,
authenticate,
permissionLevels,
collabUsers,
collabTestingWebsite
} from "./shared";
const genCategoryName = () => randomBytes(12).toString("hex");
const genCategoryWeight = (min = 10, max = 10000) => randomInt(min, max).toString();
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Categories" }).click();
});
test(`Create category`, async ({ page }) => {
await page.getByRole("button", { name: "Create category" }).click();
await page.locator("#create-category-modal").getByLabel("Name:").click();
await page.locator("#create-category-modal").getByLabel("Name:").fill(genCategoryName());
await page.locator("#create-category-modal").getByLabel("Weight:").click();
await page.locator("#create-category-modal").getByLabel("Weight:").fill(genCategoryWeight());
await page
.locator("#create-category-modal")
.getByRole("button", { name: "Create category" })
.click();
await expect(page.getByText("Successfully created category")).toBeVisible();
await expect(page.getByRole("link", { name: "All categories" })).toBeVisible();
});
test.describe("Modify", () => {
test.beforeEach(async ({ page }) => {
await page.getByRole("button", { name: "Create category" }).click();
await page.locator("#create-category-modal").getByLabel("Name:").click();
await page.locator("#create-category-modal").getByLabel("Name:").fill(genCategoryName());
await page.locator("#create-category-modal").getByLabel("Weight:").click();
await page.locator("#create-category-modal").getByLabel("Weight:").fill(genCategoryWeight());
await page
.locator("#create-category-modal")
.getByRole("button", { name: "Create category" })
.click();
});
test(`Update category`, async ({ page }) => {
await page.getByRole("button", { name: "Update" }).first().click();
const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).getByLabel("Name:").click();
await page.locator(`#${modalName}`).getByLabel("Name:").fill(genCategoryName());
await page.locator(`#${modalName}`).getByLabel("Weight:").click();
await page.locator(`#${modalName}`).getByLabel("Weight:").fill(genCategoryWeight());
await page.getByRole("button", { name: "Update category" }).click();
await expect(page.getByText("Successfully updated category")).toBeVisible();
});
test(`Delete category`, async ({ page }) => {
await page.getByRole("button", { name: "Delete" }).first().click();
await page.getByRole("button", { name: "Delete category" }).click();
await expect(page.getByText("Successfully deleted category")).toBeVisible();
});
});
});
for (const permissionLevel of permissionLevels) {
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
test.beforeEach(async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Categories" }).click();
});
test(`Create category`, async ({ page }) => {
await page.getByRole("button", { name: "Create category" }).click();
await page.locator("#create-category-modal").getByLabel("Name:").click();
await page.locator("#create-category-modal").getByLabel("Name:").fill(genCategoryName());
await page.locator("#create-category-modal").getByLabel("Weight:").click();
await page.locator("#create-category-modal").getByLabel("Weight:").fill(genCategoryWeight());
await page
.locator("#create-category-modal")
.getByRole("button", { name: "Create category" })
.evaluate((node) => node.removeAttribute("disabled"));
await page
.locator("#create-category-modal")
.getByRole("button", { name: "Create category" })
.click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully created category")).toBeVisible();
await expect(page.getByRole("link", { name: "All categories" })).toBeVisible();
}
});
test(`Update category`, async ({ page }) => {
await page.getByRole("button", { name: "Update" }).first().click();
const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).getByLabel("Name:").click();
await page.locator(`#${modalName}`).getByLabel("Name:").fill(genCategoryName());
await page.locator(`#${modalName}`).getByLabel("Weight:").click();
await page.locator(`#${modalName}`).getByLabel("Weight:").fill(genCategoryWeight());
await page
.getByRole("button", { name: "Update category" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update category" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated category")).toBeVisible();
}
});
test(`Delete category`, async ({ page }) => {
await page.getByRole("button", { name: "Delete" }).first().click();
await page
.getByRole("button", { name: "Delete category" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Delete category" }).click();
switch (permissionLevel) {
case 10:
await expect(page.getByText("Insufficient permissions")).toBeVisible();
break;
case 20:
await expect(
page
.getByText("Successfully deleted category")
.or(page.getByText("Insufficient permissions"))
).toBeVisible();
break;
case 30:
await expect(page.getByText("Successfully deleted category")).toBeVisible();
break;
}
});
});
}

View File

@@ -1,515 +0,0 @@
import { test, expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
const username = randomBytes(8).toString("hex");
const collabUsername = randomBytes(8).toString("hex");
const collabUsername2 = randomBytes(8).toString("hex");
const collabUsername3 = randomBytes(8).toString("hex");
const collabUsername4 = randomBytes(8).toString("hex");
const password = "T3stuser??!!";
const permissionLevels = [10, 20, 30];
test.describe.serial("Collaborator tests", () => {
test("Setup", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername2);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername3);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername4);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.goto("/login");
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill("Blog");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type: BlogDocs").selectOption("Docs");
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill("Documentation");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page.getByRole("button", { name: "Create article" }).click();
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill("Article-10");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername2);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername3);
await page.getByRole("combobox").selectOption("30");
await page.getByRole("button", { name: "Submit" }).click();
await page.goto("/");
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").nth(0).fill("Category-10");
await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("10");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Legal information" }).click();
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Submit" }).click();
});
for (const permissionLevel of permissionLevels) {
test(`Set collaborator permission level to ${permissionLevel}`, async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page
.locator("li")
.filter({ hasText: collabUsername })
.getByRole("button")
.first()
.click();
await page.getByRole("combobox").selectOption(permissionLevel.toString());
await page.getByRole("button", { name: "Update collaborator" }).click();
await page.goto("/");
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page
.locator("li")
.filter({ hasText: collabUsername })
.getByRole("button")
.first()
.click();
await page.getByRole("combobox").selectOption(permissionLevel.toString());
await page.getByRole("button", { name: "Update collaborator" }).click();
});
test.describe.serial(`Permission level: ${permissionLevel}`, () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Username:").fill(collabUsername);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
});
test("Update website", async ({ page }) => {
await page.locator("li").filter({ hasText: "Blog" }).getByRole("button").first().click();
await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated website")).toBeVisible();
}
});
test("Delete website", async ({ page }) => {
await page.locator("li").filter({ hasText: "Blog" }).getByRole("button").nth(1).click();
await page.getByRole("button", { name: "Delete website" }).click();
await expect(page.getByText("Insufficient permissions")).toBeVisible();
});
test("Update Global", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.locator("#global").getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated global")).toBeVisible();
}
});
test("Update Header", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.locator("#header").getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated header")).toBeVisible();
}
});
test("Update Home", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.locator("#home").getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated home")).toBeVisible();
}
});
test("Update Footer", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.locator("#footer").getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated footer")).toBeVisible();
}
});
test("Create article", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page.getByRole("button", { name: "Create article" }).click();
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill(`Article-${permissionLevel}`);
await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully created article")).toBeVisible();
}
});
test("Update article", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page
.locator("li")
.filter({ hasText: `Article-${permissionLevel}` })
.getByRole("link")
.click();
await page.getByLabel("Description:").click();
await page.getByLabel("Description:").fill("Description");
await page.getByLabel("Author:").click();
await page.getByLabel("Author:").fill("Author");
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Main content");
await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated article")).toBeVisible();
}
});
test("Delete article", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page
.locator("li")
.filter({ hasText: `Article-${permissionLevel}` })
.getByRole("button")
.click();
await page.getByRole("button", { name: "Delete article" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
}
if ([20, 30].includes(permissionLevel)) {
await expect(page.getByText("Successfully deleted article")).toBeVisible();
await page.locator("li").filter({ hasText: `Article-10` }).getByRole("button").click();
await page.getByRole("button", { name: "Delete article" }).click();
if (permissionLevel === 20) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully deleted article")).toBeVisible();
}
}
});
test("Add collaborator", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername4);
await page.getByRole("button", { name: "Submit" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully added")).toBeVisible();
}
});
test("Update collaborator", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page
.locator("li")
.filter({ hasText: collabUsername2 })
.getByRole("button")
.first()
.click();
await page.getByRole("combobox").selectOption("20");
await page.getByRole("button", { name: "Update collaborator" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated")).toBeVisible();
await page
.locator("li")
.filter({ hasText: collabUsername2 })
.getByRole("button")
.first()
.click();
await page.getByRole("combobox").selectOption("30");
await page.getByRole("button", { name: "Update collaborator" }).click();
await expect(page.getByText("Insufficient permissions")).toBeVisible();
}
});
test("Remove collaborator", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page
.locator("li")
.filter({ hasText: collabUsername2 })
.getByRole("button")
.nth(1)
.click();
await page.getByRole("button", { name: "Remove collaborator" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully removed")).toBeVisible();
await page
.locator("li")
.filter({ hasText: collabUsername3 })
.getByRole("button")
.nth(1)
.click();
await page.getByRole("button", { name: "Remove collaborator" }).click();
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.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 30) {
await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
} else {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
}
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Content updated");
await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 30) {
await expect(page.getByText("Successfully created/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 }) => {
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").nth(0).fill(`Category-${permissionLevel}`);
await page.getByRole("spinbutton", { name: "Weight:" }).click();
await page.getByRole("spinbutton", { name: "Weight:" }).fill(permissionLevel.toString());
await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully created category")).toBeVisible();
}
});
test("Update category", async ({ page }) => {
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
await page
.locator("li")
.filter({ hasText: `Category-${permissionLevel}` })
.getByRole("button")
.first()
.click();
await page.getByRole("spinbutton", { name: "Weight:" }).click();
await page
.getByRole("spinbutton", { name: "Weight:" })
.fill((permissionLevel * 2).toString());
await page.getByRole("button", { name: "Update category" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated category")).toBeVisible();
}
});
test("Delete category", async ({ page }) => {
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
await page
.locator("li")
.filter({ hasText: `Category-${permissionLevel}` })
.getByRole("button")
.nth(1)
.click();
await page.getByRole("button", { name: "Delete category" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
}
if ([20, 30].includes(permissionLevel)) {
await expect(page.getByText("Successfully deleted category")).toBeVisible();
await page
.locator("li")
.filter({ hasText: "Category-10" })
.getByRole("button")
.nth(1)
.click();
await page.getByRole("button", { name: "Delete category" }).click();
if (permissionLevel === 20) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully deleted category")).toBeVisible();
}
}
});
test("Publish website", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByRole("button", { name: "Publish" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully published website")).toBeVisible();
}
});
});
}
test("Delete all accounts", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page
.locator("#delete-account-modal")
.getByRole("button", { name: "Delete account" })
.click();
await page.getByLabel("Username:").fill(collabUsername);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page
.locator("#delete-account-modal")
.getByRole("button", { name: "Delete account" })
.click();
await page.getByLabel("Username:").fill(collabUsername2);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page
.locator("#delete-account-modal")
.getByRole("button", { name: "Delete account" })
.click();
await page.getByLabel("Username:").fill(collabUsername3);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page
.locator("#delete-account-modal")
.getByRole("button", { name: "Delete account" })
.click();
await page.getByLabel("Username:").fill(collabUsername4);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page
.locator("#delete-account-modal")
.getByRole("button", { name: "Delete account" })
.click();
});
});

View File

@@ -0,0 +1,209 @@
import { test, expect } from "@playwright/test";
import { randomBytes, randomInt, type UUID } from "node:crypto";
import {
userOwner,
register,
authenticate,
permissionLevels,
collabUsers,
collabTestingWebsite,
userCollab10,
userCollab20,
userCollab30
} from "./shared";
const genUsername = () => randomBytes(8).toString("hex") as UUID;
const pickPermissionLevel = () => permissionLevels[randomInt(permissionLevels.length)].toString();
test.describe("Website owner", () => {
test(`Add collaborator`, async ({ page }) => {
const addUsername = genUsername();
await register(addUsername, page);
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.locator("#add-collaborator-modal").getByLabel("Username:").click();
await page.locator("#add-collaborator-modal").getByLabel("Username:").fill(addUsername);
await page
.locator("#add-collaborator-modal")
.getByLabel("Permission level:")
.selectOption(pickPermissionLevel());
await page
.locator("#add-collaborator-modal")
.getByRole("button", { name: "Add collaborator" })
.click();
await expect(page.getByText("Successfully added collaborator")).toBeVisible();
await expect(page.getByRole("link", { name: "All collaborators" })).toBeVisible();
});
test.describe("Modify", () => {
let modifyUsername: UUID;
test.beforeEach(async ({ page }) => {
modifyUsername = genUsername();
await register(modifyUsername, page);
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.locator("#add-collaborator-modal").getByLabel("Username:").click();
await page.locator("#add-collaborator-modal").getByLabel("Username:").fill(modifyUsername);
await page
.locator("#add-collaborator-modal")
.getByLabel("Permission level:")
.selectOption(pickPermissionLevel());
await page
.locator("#add-collaborator-modal")
.getByRole("button", { name: "Add collaborator" })
.click();
});
test(`Update collaborator`, async ({ page }) => {
await page
.locator("li")
.filter({ hasText: modifyUsername })
.getByRole("button", { name: "Update" })
.first()
.click();
const modalName = page.url().split("#")[1];
await page
.locator(`#${modalName}`)
.getByLabel("Permission level:")
.selectOption(pickPermissionLevel());
await page.getByRole("button", { name: "Update collaborator" }).click();
await expect(page.getByText("Successfully updated collaborator")).toBeVisible();
});
test(`Remove collaborator`, async ({ page }) => {
await page
.locator("li")
.filter({ hasText: modifyUsername })
.getByRole("button", { name: "Remove" })
.first()
.click();
await page.getByRole("button", { name: "Remove collaborator" }).click();
await expect(page.getByText("Successfully removed collaborator")).toBeVisible();
});
});
});
for (const permissionLevel of permissionLevels) {
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
test(`Add collaborator`, async ({ page }) => {
const addUsername = genUsername();
await register(addUsername, page);
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.locator("#add-collaborator-modal").getByLabel("Username:").click();
await page.locator("#add-collaborator-modal").getByLabel("Username:").fill(addUsername);
await page
.locator("#add-collaborator-modal")
.getByLabel("Permission level:")
.selectOption(pickPermissionLevel());
await page
.locator("#add-collaborator-modal")
.getByRole("button", { name: "Add collaborator" })
.evaluate((node) => node.removeAttribute("disabled"));
await page
.locator("#add-collaborator-modal")
.getByRole("button", { name: "Add collaborator" })
.click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(
page
.getByText("Successfully added collaborator")
.or(page.getByText("Insufficient permissions"))
).toBeVisible();
}
});
test(`Update collaborator`, async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page
.locator("li")
.filter({ hasNotText: new RegExp(`${userCollab10}|${userCollab20}|${userCollab30}`) })
.getByRole("button", { name: "Update" })
.first()
.click();
const modalName = page.url().split("#")[1];
await page
.locator(`#${modalName}`)
.getByLabel("Permission level:")
.selectOption(pickPermissionLevel());
await page
.getByRole("button", { name: "Update collaborator" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update collaborator" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(
page
.getByText("Successfully updated collaborator")
.or(page.getByText("Insufficient permissions"))
).toBeVisible();
}
});
test(`Remove collaborator`, async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page
.locator("li")
.filter({ hasNotText: new RegExp(`${userCollab10}|${userCollab20}|${userCollab30}`) })
.getByRole("button", { name: "Remove" })
.first()
.click();
await page
.getByRole("button", { name: "Remove collaborator" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Remove collaborator" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(
page
.getByText("Successfully removed collaborator")
.or(page.getByText("Insufficient permissions"))
).toBeVisible();
}
});
});
}

View File

@@ -0,0 +1,106 @@
import { test, expect } from "@playwright/test";
import {
userOwner,
authenticate,
permissionLevels,
collabUsers,
collabTestingWebsite
} from "./shared";
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
});
test(`Create website`, async ({ page }) => {
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type:").selectOption("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").click();
await page.locator("#create-website-modal").getByLabel("Title:").fill("Blog");
await page
.locator("#create-website-modal")
.getByRole("button", { name: "Create website" })
.click();
const successCreation = page.getByText("Successfully created website");
const limitExceeded = page.getByText("Limit of 3 websites exceeded");
await expect(successCreation.or(limitExceeded)).toBeVisible();
await expect(page.getByRole("link", { name: "All websites" })).toBeVisible();
});
test.describe("Modify", () => {
test.beforeEach(async ({ page }) => {
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type:").selectOption("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").click();
await page.locator("#create-website-modal").getByLabel("Title:").fill("Blog");
await page
.locator("#create-website-modal")
.getByRole("button", { name: "Create website" })
.click();
});
test(`Update website`, async ({ page }) => {
await page
.locator("li")
.filter({ hasNotText: collabTestingWebsite })
.getByRole("button", { name: "Update" })
.first()
.click();
const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).getByLabel("Title:").click();
await page.locator(`#${modalName}`).getByLabel("Title:").fill(`${"Blog"} updated`);
await page.getByRole("button", { name: "Update website" }).click();
await expect(page.getByText("Successfully updated website")).toBeVisible();
});
test(`Delete website`, async ({ page }) => {
await page
.locator("li")
.filter({ hasNotText: collabTestingWebsite })
.getByRole("button", { name: "Delete" })
.first()
.click();
await page.getByRole("button", { name: "Delete website" }).click();
await expect(page.getByText("Successfully deleted website")).toBeVisible();
});
});
});
for (const permissionLevel of permissionLevels) {
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
test.beforeEach(async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
});
test("Update website", async ({ page }) => {
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("button", { name: "Update" })
.click();
await page
.getByRole("button", { name: "Update website" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update website" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated website")).toBeVisible();
}
});
test("Delete website", async ({ page }) => {
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("button", { name: "Delete" })
.click();
await page
.getByRole("button", { name: "Delete website" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Delete website" }).click();
await expect(page.getByText("Insufficient permissions")).toBeVisible();
});
});
}

View File

@@ -0,0 +1,64 @@
import { test } from "@playwright/test";
import {
allUsers,
register,
authenticate,
userOwner,
collabTestingWebsite,
permissionLevels,
collabUsers,
userDummy
} from "./shared";
for (const username of allUsers) {
test(`Register user "${username}`, async ({ page }) => {
await register(username, page);
});
}
test.describe("Collaborator testing website", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
});
test("Create website", async ({ page }) => {
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type:").selectOption("Docs");
await page.locator("#create-website-modal").getByLabel("Title:").click();
await page.locator("#create-website-modal").getByLabel("Title:").fill(collabTestingWebsite);
await page
.locator("#create-website-modal")
.getByRole("button", { name: "Create website" })
.click();
});
for (const permissionLevel of permissionLevels) {
test(`Add collaborator "${collabUsers.get(permissionLevel)}"`, async ({ page }) => {
await page.getByRole("link", { name: collabTestingWebsite }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsers.get(permissionLevel)!);
await page
.locator("#add-collaborator-modal")
.getByLabel("Permission level:")
.selectOption(permissionLevel.toString());
await page
.locator("#add-collaborator-modal")
.getByRole("button", { name: "Add collaborator" })
.click();
});
}
});
test("Dummy user website", async ({ page }) => {
await authenticate(userDummy, page);
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type:").selectOption("Blog");
await page.locator("#create-website-modal").getByLabel("Title:").click();
await page.locator("#create-website-modal").getByLabel("Title:").fill("Dummy");
await page
.locator("#create-website-modal")
.getByRole("button", { name: "Create website" })
.click();
});

View File

@@ -0,0 +1,31 @@
import { test } from "@playwright/test";
import { password, authenticate, userOwner } from "./shared";
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
});
/* test("Delete all regular users", async ({ page }) => {
await page.getByRole("link", { name: "Manage" }).click();
await page.waitForSelector("tbody");
const userRows = await page.locator("tbody > tr").filter({ hasNotText: userOwner }).all();
for (const row of userRows) {
await row.getByRole("button", { name: "Manage" }).click();
const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).locator('summary:has-text("Delete")').click();
await page.locator(`#${modalName}`).getByRole("button", { name: "Delete user" }).click();
}
});
test("Delete admin account", async ({ page }) => {
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page
.locator("#delete-account-modal")
.getByRole("button", { name: "Delete account" })
.click();
}); */

View File

@@ -0,0 +1,109 @@
import { test, expect } from "@playwright/test";
import {
userOwner,
authenticate,
permissionLevels,
collabUsers,
collabTestingWebsite
} from "./shared";
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Legal information" }).click();
});
test(`Create/update legal information`, async ({ page }) => {
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Update legal information" }).click();
await expect(page.getByText("Successfully created/updated legal information")).toBeVisible();
});
test(`Delete legal information`, async ({ page }) => {
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Arbitrary content");
await page.getByRole("button", { name: "Update 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 information")).toBeVisible();
});
});
for (const permissionLevel of permissionLevels) {
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
test(`Create/update legal information`, async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Legal information" }).click();
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Random content");
await page
.getByRole("button", { name: "Update legal information" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update legal information" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(
page.getByText("Successfully created/updated legal information")
).toBeVisible();
}
});
test(`Delete legal information`, async ({ page, browserName }) => {
test.skip(browserName === "firefox", "Some issues with Firefox in headful mode");
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Legal information" }).click();
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Even more content");
await page.getByRole("button", { name: "Update legal information" }).click();
await page.waitForResponse(/createUpdateLegalInformation/);
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Logout" }).click();
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Legal information" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await page
.getByRole("button", { name: "Delete legal information" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Delete legal information" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully deleted legal information")).toBeVisible();
}
});
});
}

View File

@@ -0,0 +1,67 @@
import { test, expect } from "@playwright/test";
import { userOwner, userDummy, register, authenticate } from "./shared";
const userDeleted = "test-deleted-m";
test(`Update user website limit`, async ({ page }) => {
await authenticate(userOwner, page);
await page.getByRole("link", { name: "Manage" }).click();
await page
.locator("tr")
.filter({ hasNotText: userOwner })
.getByRole("button", { name: "Manage" })
.first()
.click();
const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).getByLabel("Number of websites allowed:").click();
await page.locator(`#${modalName}`).getByLabel("Number of websites allowed:").fill("5");
await page.getByRole("button", { name: "Update website limit" }).click();
await expect(page.getByText("Successfully updated user website limit")).toBeVisible();
});
test(`Update user website storage limit`, async ({ page }) => {
await authenticate(userOwner, page);
await page.getByRole("link", { name: "Manage" }).click();
await page
.locator("tr")
.filter({ hasText: userDummy })
.getByRole("button", { name: "Manage" })
.first()
.click();
const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).locator("details > summary").first().click();
await page
.locator(`#${modalName}`)
.locator("details")
.getByLabel("Storage limit in MB:")
.first()
.click();
await page
.locator(`#${modalName}`)
.locator("details")
.getByLabel("Storage limit in MB:")
.first()
.fill("555");
await page
.locator(`#${modalName}`)
.locator("details")
.getByRole("button", { name: "Update storage limit" })
.click();
await expect(page.getByText("Successfully updated user website storage size")).toBeVisible();
});
test(`Delete user`, async ({ page }) => {
await register(userDeleted, page);
await authenticate(userOwner, page);
await page.getByRole("link", { name: "Manage" }).click();
await page
.locator("tr")
.filter({ hasText: userDeleted })
.getByRole("button", { name: "Manage" })
.first()
.click();
const modalName = page.url().split("#")[1];
await page.locator(`#${modalName}`).locator('summary:has-text("Delete")').click();
await page.locator(`#${modalName}`).getByRole("button", { name: "Delete user" }).click();
await expect(page.getByText("Successfully deleted user")).toBeVisible();
});

View File

@@ -0,0 +1,135 @@
import { test, expect } from "@playwright/test";
import {
userOwner,
authenticate,
permissionLevels,
collabUsers,
collabTestingWebsite
} from "./shared";
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Publish" }).click();
});
test(`Publish website`, async ({ page }) => {
await page.getByRole("button", { name: "Publish" }).click();
await expect(page.getByText("Successfully published website")).toBeVisible();
await expect(page.getByText("Your website is published at")).toBeVisible();
});
test(`Set custom domain prefix`, async ({ page }) => {
await page.getByLabel("Prefix:").click();
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
await page.getByLabel("Prefix:").fill("example-prefix");
await page.getByRole("button", { name: "Update domain prefix" }).click();
await expect(page.getByText("Successfully created/updated domain prefix")).toBeVisible();
});
test(`Delete custom domain prefix`, async ({ page }) => {
await page.getByLabel("Prefix:").click();
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
await page.getByLabel("Prefix:").fill("example-prefix");
await page.getByRole("button", { name: "Update domain prefix" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await page.getByRole("button", { name: "Delete domain prefix" }).click();
await expect(page.getByText("Successfully deleted domain prefix")).toBeVisible();
});
});
for (const permissionLevel of permissionLevels) {
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
test(`Publish website`, async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Publish" }).click();
await page
.getByRole("button", { name: "Publish" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Publish" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully published website")).toBeVisible();
await expect(page.getByText("Your website is published at")).toBeVisible();
}
});
test(`Set custom domain prefix`, async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByLabel("Prefix:").click();
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
await page.getByLabel("Prefix:").fill("new-prefix");
await page
.getByRole("button", { name: "Update domain prefix" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update domain prefix" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully created/updated domain prefix")).toBeVisible();
}
});
test(`Delete custom domain prefix`, async ({ page, browserName }) => {
test.skip(browserName === "firefox", "Some issues with Firefox in headful mode");
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByLabel("Prefix:").click();
await page.getByLabel("Prefix:").press("ControlOrMeta+a");
await page.getByLabel("Prefix:").fill("new-prefix");
await page.getByRole("button", { name: "Update domain prefix" }).click();
await page.waitForResponse(/createUpdateCustomDomainPrefix/);
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Logout" }).click();
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await page
.getByRole("button", { name: "Delete domain prefix" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Delete domain prefix" }).click();
if ([10, 20].includes(permissionLevel)) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully deleted domain prefix")).toBeVisible();
}
});
});
}

View File

@@ -0,0 +1,175 @@
import { test, expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
userOwner,
authenticate,
permissionLevels,
collabUsers,
collabTestingWebsite
} from "./shared";
const genRandomHex = () => `#${randomBytes(3).toString("hex")}`;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
test.describe("Website owner", () => {
test.beforeEach(async ({ page }) => {
await authenticate(userOwner, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
});
test("Update global", async ({ page, browserName }) => {
test.skip(browserName === "firefox", "Some issues with Firefox in headless mode");
await page.getByLabel("Background color dark theme:").click();
await page.getByLabel("Background color dark theme:").fill(genRandomHex());
await page.getByLabel("Background color light theme:").click();
await page.getByLabel("Background color light theme:").fill(genRandomHex());
await page.getByLabel("Accent color dark theme:").click();
await page.getByLabel("Accent color dark theme:").fill(genRandomHex());
await page.getByLabel("Accent color light theme:").click();
await page.getByLabel("Accent color light theme:").fill(genRandomHex());
await page.getByLabel("Favicon:").click();
await page
.getByLabel("Favicon:")
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
await page.getByRole("button", { name: "Update global" }).click();
await expect(page.getByText("Successfully updated global")).toBeVisible();
});
test("Update header", async ({ page, browserName }) => {
test.skip(browserName === "firefox", "Some issues with Firefox in headless mode");
await page.getByLabel("Logo type:").selectOption("image");
await page.getByLabel("Logo text:").click();
await page.getByLabel("Logo text:").press("ControlOrMeta+a");
await page.getByLabel("Logo text:").fill("Logo text");
await page.getByLabel("Logo image:").click();
await page
.getByLabel("Logo image")
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
await page.getByRole("button", { name: "Update header" }).click();
await expect(page.getByText("Successfully updated header")).toBeVisible();
});
test("Update home", async ({ page }) => {
await page.getByLabel("Description:").click();
await page.getByLabel("Description:").fill("Description comes here");
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Updated main content");
await page.getByRole("button", { name: "Update home" }).click();
await expect(page.getByText("Successfully updated home")).toBeVisible();
});
test("Update footer", async ({ page }) => {
await page.getByLabel("Additional text:").click();
await page.getByLabel("Additional text:").press("ControlOrMeta+a");
await page.getByLabel("Additional text:").fill("Updated footer content");
await page.getByRole("button", { name: "Update footer" }).click();
await expect(page.getByText("Successfully updated footer")).toBeVisible();
});
});
for (const permissionLevel of permissionLevels) {
test.describe(`Website collaborator (Permission level: ${permissionLevel})`, () => {
test.beforeEach(async ({ page }) => {
await authenticate(collabUsers.get(permissionLevel)!, page);
await page
.locator("li")
.filter({ hasText: collabTestingWebsite })
.getByRole("link", { name: collabTestingWebsite })
.click();
});
test("Update global", async ({ page, browserName }) => {
test.skip(browserName === "firefox", "Some issues with Firefox in headless mode");
await page.getByLabel("Background color dark theme:").click();
await page.getByLabel("Background color dark theme:").fill(genRandomHex());
await page.getByLabel("Background color light theme:").click();
await page.getByLabel("Background color light theme:").fill(genRandomHex());
await page.getByLabel("Accent color dark theme:").click();
await page.getByLabel("Accent color dark theme:").fill(genRandomHex());
await page.getByLabel("Accent color light theme:").click();
await page.getByLabel("Accent color light theme:").fill(genRandomHex());
await page.getByLabel("Favicon:").click();
await page
.getByLabel("Favicon:")
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
await page
.getByRole("button", { name: "Update global" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update global" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated global")).toBeVisible();
}
});
test("Update header", async ({ page, browserName }) => {
test.skip(browserName === "firefox", "Some issues with Firefox in headless mode");
await page.getByLabel("Logo type:").selectOption("image");
await page.getByLabel("Logo text:").click();
await page.getByLabel("Logo text:").press("ControlOrMeta+a");
await page.getByLabel("Logo text:").fill("Logo text");
await page.getByLabel("Logo image:").click();
await page
.getByLabel("Logo image")
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
await page
.getByRole("button", { name: "Update header" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update header" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated header")).toBeVisible();
}
});
test("Update home", async ({ page }) => {
await page.getByLabel("Description:").click();
await page.getByLabel("Description:").fill("Description comes here");
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("ControlOrMeta+a");
await page.getByLabel("Main content:").fill("## Updated main content");
await page
.getByRole("button", { name: "Update home" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update home" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated home")).toBeVisible();
}
});
test("Update footer", async ({ page }) => {
await page.getByLabel("Additional text:").click();
await page.getByLabel("Additional text:").press("ControlOrMeta+a");
await page.getByLabel("Additional text:").fill("Updated footer content");
await page
.getByRole("button", { name: "Update footer" })
.evaluate((node) => node.removeAttribute("disabled"));
await page.getByRole("button", { name: "Update footer" }).click();
if (permissionLevel === 10) {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
} else {
await expect(page.getByText("Successfully updated footer")).toBeVisible();
}
});
});
}

35
web-app/tests/shared.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { Page } from "@playwright/test";
export const userOwner = "test-owner";
export const userCollab10 = "test-collab10";
export const userCollab20 = "test-collab20";
export const userCollab30 = "test-collab30";
export const userDummy = "test-dummy";
export const collabUsers = new Map([
[10, userCollab10],
[20, userCollab20],
[30, userCollab30]
]);
export const permissionLevels = [10, 20, 30];
export const allUsers = [userOwner, userCollab10, userCollab20, userCollab30, userDummy];
export const password = "T3stinguser?!";
export const contentTypes = ["Blog", "Docs"];
export const collabTestingWebsite = "Collaborator testing";
export const register = async (username: string, page: Page) => {
await page.goto("/register");
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Register" }).click();
};
export const authenticate = async (username: string, page: Page) => {
await page.goto("/login");
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Login" }).click();
};

View File

@@ -1,384 +0,0 @@
import { test as base, expect, type Page } from "@playwright/test";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { randomBytes } from "node:crypto";
import { platform } from "node:os";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const username = randomBytes(8).toString("hex");
const collabUsername = randomBytes(8).toString("hex");
const password = "T3stuser??!!";
const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
await page.goto("/login");
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await use(page);
}
});
test.describe.serial("Website tests", () => {
test("Register", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(username);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername);
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
});
test("Create websites", async ({ authenticatedPage: page }) => {
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill("Blog");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("link", { name: "All websites" })).toBeVisible();
await expect(page.getByText("Search & Filter")).toBeVisible();
await expect(page.getByText("Blog Type: Blog Created at:")).toBeVisible();
await page.getByRole("button", { name: "Create website" }).click();
await page.getByLabel("Type: BlogDocs").selectOption("Docs");
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill("Documentation");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("link", { name: "All websites" })).toBeVisible();
await expect(page.getByText("Search & Filter")).toBeVisible();
await expect(page.getByText("Documentation Type: Docs")).toBeVisible();
});
test("Update websites", async ({ authenticatedPage: page }) => {
await page.locator("li").filter({ hasText: "Blog" }).getByRole("button").first().click();
await page.getByRole("textbox", { name: "Title" }).click();
await page.getByRole("textbox", { name: "Title" }).fill("Blog updated");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("link", { name: "Blog updated" })).toBeVisible();
await page
.locator("li")
.filter({ hasText: "Documentation" })
.getByRole("button")
.first()
.click();
await page.getByRole("textbox", { name: "Title" }).click();
await page.getByRole("textbox", { name: "Title" }).fill("Documentation updated");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("link", { name: "Documentation updated" })).toBeVisible();
});
test.describe.serial("Blog", () => {
test.describe.serial("Update settings", () => {
test("Global", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByLabel("Background color dark theme: ").click();
await page.getByLabel("Background color dark theme:").fill("#3975a2");
await page.getByLabel("Background color light theme:").click();
await page.getByLabel("Background color light theme:").fill("#41473e");
await page.getByLabel("Accent color dark theme: ").click();
await page.getByLabel("Accent color dark theme:").fill("#3975a2");
await page.getByLabel("Accent color light theme:").click();
await page.getByLabel("Accent color light theme:").fill("#41473e");
await page.locator("#global").getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated global")).toBeVisible();
await page.getByLabel("Favicon:").click();
await page
.getByLabel("Favicon:")
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
await page.locator("#global").getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated global")).toBeVisible();
});
test("Header", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByLabel("Logo text:").click();
await page.getByLabel("Logo text:").fill("archtika Blog updated");
await page.locator("#header").getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated header")).toBeVisible();
await page.getByLabel("Logo type: TextImage").selectOption("image");
await page.getByLabel("Logo image:").click();
await page
.getByLabel("Logo image:")
.setInputFiles(join(__dirname, "sample-files", "archtika-logo-512x512.png"));
await page.locator("#header").getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated header")).toBeVisible();
});
test("Home", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").press("Control+a");
await page.getByLabel("Main content:").fill("## Some new content comes here");
await expect(page.getByRole("link", { name: "Some new content comes here" })).toBeVisible();
await page.locator("#home").getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated home")).toBeVisible();
});
test("Footer", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByLabel("Additional text:").click();
await page
.getByLabel("Additional text:")
.fill(
"archtika is a free, open, modern, performant and lightweight CMS updated content comes here"
);
await page.locator("#footer").getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated footer")).toBeVisible();
});
});
test.describe.serial("Articles", () => {
test("Create article", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page.getByRole("button", { name: "Create article" }).click();
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill("Test article");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("link", { name: "All articles" })).toBeVisible();
await expect(page.getByText("Search & Filter")).toBeVisible();
await expect(page.getByText("Test article Edit Delete")).toBeVisible();
});
test("Update article", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page.getByRole("link", { name: "Edit" }).click();
await page.getByLabel("Description:").click();
await page.getByLabel("Description:").fill("Sample article description");
await page.getByLabel("Author:").click();
await page.getByLabel("Author:").fill("John Doe");
await page.getByLabel("Main content:").click();
await page
.getByLabel("Main content:")
.fill(
"## Section\n\n### Subsection\n\n## Second section\n\n### Second subsection\n\n#### Sub Sub section"
);
await expect(
page.getByText(
"Table of contents SectionSubsectionSecond sectionSecond subsectionSub Sub"
)
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Section", exact: true }).getByRole("link")
).toBeVisible();
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated article")).toBeVisible();
});
test("Paste image", async ({ authenticatedPage: page, context }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page.getByRole("link", { name: "Edit" }).click();
await page.getByLabel("Main content:").click();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const isMac = platform() === "darwin";
const modifier = isMac ? "Meta" : "Control";
const clipPage = await context.newPage();
await clipPage.goto("https://picsum.photos/400/400.jpg");
await clipPage.keyboard.press(`${modifier}+KeyC`);
await page.bringToFront();
await page.keyboard.press("Enter");
await page.keyboard.press("Enter");
await page.keyboard.press(`${modifier}+KeyV`);
await expect(page.getByText("Successfully uploaded image")).toBeVisible();
});
test("Delete article", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await page.getByRole("button", { name: "Delete article" }).click();
await expect(page.getByText("Successfully deleted article")).toBeVisible();
});
});
test.describe.serial("Collaborators", () => {
test("Add collaborator", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Add collaborator" }).click();
await page.getByLabel("Username:").click();
await page.getByLabel("Username:").fill(collabUsername);
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully added")).toBeVisible();
});
test("Update collaborator", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Update" }).click();
await page.getByRole("combobox").selectOption("20");
await page.getByRole("button", { name: "Update collaborator" }).click();
await expect(page.getByText("Successfully updated")).toBeVisible();
});
test("Remove collaborator", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Collaborators" }).click();
await page.getByRole("button", { name: "Remove" }).click();
await page.getByRole("button", { name: "Remove collaborator" }).click();
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.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Content updated");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully created/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("Categories", () => {
test("Create category", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").nth(0).fill("Category");
await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("1000");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully created category")).toBeVisible();
await expect(page.getByRole("link", { name: "All categories" })).toBeVisible();
await expect(page.getByText("Category (1000)")).toBeVisible();
});
test("Update category", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Update" }).click();
await page.getByRole("spinbutton", { name: "Weight:" }).click();
await page.getByRole("spinbutton", { name: "Weight:" }).fill("500");
await page.getByRole("button", { name: "Update category" }).click();
await expect(page.getByText("Successfully updated category")).toBeVisible();
await expect(page.getByText("Category (500)")).toBeVisible();
});
test("Delete category", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await page.getByRole("button", { name: "Delete category" }).click();
await expect(page.getByText("Successfully deleted category")).toBeVisible();
await expect(page.getByRole("link", { name: "All categories" })).toBeHidden();
});
});
test("Article", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").nth(0).fill("Category");
await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("1000");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Articles" }).click();
await page.getByRole("button", { name: "Create article" }).click();
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill("Article");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Edit" }).click();
await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("1000");
await page.getByLabel("Title:").click();
await page.getByLabel("Title:").fill("Article");
await page.getByLabel("Description:").click();
await page.getByLabel("Description:").fill("Testing out this article");
await page.getByLabel("Author:").click();
await page.getByLabel("Author:").fill("John Doe");
await page.getByLabel("Main content:").click();
await page
.getByLabel("Main content:")
.fill(
"## Main content comes in here\n\n### First section\n\n### Second section\n\n## More"
);
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated article")).toBeVisible();
await expect(page.getByText("Table of contents Main")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Main content comes in here" }).getByRole("link")
).toBeVisible();
});
});
test("Publish websites", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByRole("button", { name: "Publish" }).click();
await expect(page.getByText("Successfully published website")).toBeVisible();
await expect(page.getByText("Your website is published at")).toBeVisible();
await page.goto("/");
await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Publish" }).click();
await page.getByRole("button", { name: "Publish" }).click();
await expect(page.getByText("Successfully published website")).toBeVisible();
await expect(page.getByText("Your website is published at")).toBeVisible();
});
test("Delete websites", async ({ authenticatedPage: page }) => {
await page.getByRole("button", { name: "Delete" }).nth(1).click();
await page.getByRole("button", { name: "Delete website" }).click();
await expect(page.getByText("Successfully deleted website")).toBeVisible();
await page.getByRole("button", { name: "Delete" }).click();
await page.getByRole("button", { name: "Delete website" }).click();
await expect(page.getByText("Successfully deleted website")).toBeVisible();
await expect(page.getByRole("link", { name: "All websites" })).toBeHidden();
});
test("Delete accounts", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page
.locator("#delete-account-modal")
.getByRole("button", { name: "Delete account" })
.click();
await page.getByLabel("Username:").fill(collabUsername);
await page.getByLabel("Password:").fill(password);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Account" }).click();
await page.getByRole("button", { name: "Delete account" }).click();
await page.getByLabel("Password:").click();
await page.getByLabel("Password:").fill(password);
await page
.locator("#delete-account-modal")
.getByRole("button", { name: "Delete account" })
.click();
});
});