diff --git a/rest-api/db/migrations/20240901135831_restrict_file_operations.sql b/rest-api/db/migrations/20240901135831_restrict_file_operations.sql new file mode 100644 index 0000000..98ffa23 --- /dev/null +++ b/rest-api/db/migrations/20240901135831_restrict_file_operations.sql @@ -0,0 +1,76 @@ +-- migrate:up +CREATE OR REPLACE FUNCTION api.upload_file (BYTEA, OUT file_id UUID) +AS $$ +DECLARE + _headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON; + _website_id UUID := (_headers ->> 'x-website-id')::UUID; + _mimetype TEXT := _headers ->> 'x-mimetype'; + _original_filename TEXT := _headers ->> 'x-original-filename'; + _allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp']; + _max_file_size INT := 5 * 1024 * 1024; + _has_access BOOLEAN; +BEGIN + _has_access = internal.user_has_website_access (_website_id, 20); + IF OCTET_LENGTH($1) = 0 THEN + RAISE invalid_parameter_value + USING message = 'No file data was provided'; + END IF; + IF _mimetype IS NULL OR _mimetype NOT IN ( + SELECT + UNNEST(_allowed_mimetypes)) THEN + RAISE invalid_parameter_value + USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp'; + END IF; + IF OCTET_LENGTH($1) > _max_file_size THEN + RAISE program_limit_exceeded + USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024)); + END IF; + INSERT INTO internal.media (website_id, blob, mimetype, original_name) + VALUES (_website_id, $1, _mimetype, _original_filename) + RETURNING + id INTO file_id; +END; +$$ +LANGUAGE plpgsql +SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user; + +-- migrate:down +DROP FUNCTION api.upload_file (BYTEA); + +CREATE FUNCTION api.upload_file (BYTEA, OUT file_id UUID) +AS $$ +DECLARE + _headers JSON := CURRENT_SETTING('request.headers', TRUE)::JSON; + _website_id UUID := (_headers ->> 'x-website-id')::UUID; + _mimetype TEXT := _headers ->> 'x-mimetype'; + _original_filename TEXT := _headers ->> 'x-original-filename'; + _allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp']; + _max_file_size INT := 5 * 1024 * 1024; +BEGIN + IF OCTET_LENGTH($1) = 0 THEN + RAISE invalid_parameter_value + USING message = 'No file data was provided'; + END IF; + IF _mimetype IS NULL OR _mimetype NOT IN ( + SELECT + UNNEST(_allowed_mimetypes)) THEN + RAISE invalid_parameter_value + USING message = 'Invalid MIME type. Allowed types are: png, jpg, webp'; + END IF; + IF OCTET_LENGTH($1) > _max_file_size THEN + RAISE program_limit_exceeded + USING message = FORMAT('File size exceeds the maximum limit of %s MB', _max_file_size / (1024 * 1024)); + END IF; + INSERT INTO internal.media (website_id, blob, mimetype, original_name) + VALUES (_website_id, $1, _mimetype, _original_filename) + RETURNING + id INTO file_id; +END; +$$ +LANGUAGE plpgsql +SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION api.upload_file (BYTEA) TO authenticated_user; + diff --git a/web-app/.gitignore b/web-app/.gitignore index 79518f7..b549aa9 100644 --- a/web-app/.gitignore +++ b/web-app/.gitignore @@ -19,3 +19,6 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Playwright +test-results \ No newline at end of file diff --git a/web-app/src/routes/(authenticated)/+page.svelte b/web-app/src/routes/(authenticated)/+page.svelte index cff2465..7ac662b 100644 --- a/web-app/src/routes/(authenticated)/+page.svelte +++ b/web-app/src/routes/(authenticated)/+page.svelte @@ -77,9 +77,19 @@ diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/articles/+page.svelte b/web-app/src/routes/(authenticated)/website/[websiteId]/articles/+page.svelte index ba790d9..23d3e12 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/articles/+page.svelte +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/articles/+page.svelte @@ -66,9 +66,19 @@ diff --git a/web-app/tests/website.spec.ts b/web-app/tests/website.spec.ts index 58557ff..2c3916a 100644 --- a/web-app/tests/website.spec.ts +++ b/web-app/tests/website.spec.ts @@ -125,7 +125,7 @@ test.describe("Blog", () => { }); }); - test.describe("Articles", () => { + test.describe.serial("Articles", () => { test("Create article", async ({ authenticatedPage: page }) => { await page.getByRole("link", { name: "Blog" }).click(); await page.getByRole("link", { name: "Articles" }).click(); @@ -171,7 +171,7 @@ test.describe("Blog", () => { }); }); - test.describe("Collaborators", () => { + test.describe.serial("Collaborators", () => { test("Add collaborator", async ({ authenticatedPage: page }) => { await page.getByRole("link", { name: "Blog" }).click(); await page.getByRole("link", { name: "Collaborators" }).click(); @@ -202,7 +202,7 @@ test.describe("Blog", () => { }); test.describe("Docs", () => { - test.describe("Categories", () => { + test.describe.serial("Categories", () => { test("Create category", async ({ authenticatedPage: page }) => { await page.getByRole("link", { name: "Documentation" }).click(); await page.getByRole("link", { name: "Categories" }).click(); @@ -299,7 +299,7 @@ test("Delete websites", async ({ authenticatedPage: page }) => { await expect(page.getByRole("link", { name: "All websites" })).toBeHidden(); }); -test("Delete account", async ({ authenticatedPage: page }) => { +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();