diff --git a/.gitignore b/.gitignore index 1cd5fbcf8c2fe948a71c69d64fad98ebda7ad183..c4383a71d4bd5c122b7842e274fe368063114000 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ db/ .env .DS_Store src/server/html/ -.idea/ \ No newline at end of file +.idea/ +src/public/assets/images/catalogs/posters +src/public/assets/videos/catalogs/trailers \ No newline at end of file diff --git a/README.md b/README.md index 74e59f30e0b01fc99b8b4c9a6b4a894012aa8141..50c334d8e219af794f2be0835381f5809e138b05 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,20 @@ menambahkan catalog anime dan drama. ## Daftar Isi +- [Daftar Perubahan](#daftar-perubahan) - [Daftar Requirements](#daftar-requirements) - [Cara Instalasi](#cara-instalasi) - [Cara Menjalankan Server](#cara-menjalankan-server) - [Tangkapan Layar](#tangkapan-layar) - [Pembagian Tugas](#pembagian-tugas) +## Daftar Perubahan + +- GET /catalog-request: Menampilkan view html +- POST /api/v2/catalog-request: Mengirim request untuk membuat catalog request +- DELETE /api/v2/catalog-request/poster/<poster-name>: Mengirim request untuk delete poster +- DELETE /api/v2/catalog-request/trailer/<trailer-name>: Mengirim request untuk delete trailer + ## Daftar Requirements - Docker @@ -96,7 +104,7 @@ install sesuai dengan sistem operasi Anda. ### Server Side | Tugas | NIM | -|--------------------|----------| +| ------------------ | -------- | | Setup Awal Project | 13521150 | | Sign In | 13521048 | | Sign Up | 13521048 | @@ -116,7 +124,7 @@ install sesuai dengan sistem operasi Anda. ### Client Side | Tugas | NIM | -|--------------------|----------| +| ------------------ | -------- | | Setup Awal Project | 13521150 | | Sign In | 13521048 | | Sign Up | 13521048 | @@ -131,4 +139,4 @@ install sesuai dengan sistem operasi Anda. | Home | 13521150 | | Watchlist Create | 13521150 | | Watchlist Delete | 13521150 | -| Watchlist Edit | 13521150 | \ No newline at end of file +| Watchlist Edit | 13521150 | diff --git a/src/migration/db.sql b/src/migration/db.sql index 14fc6159cb1cb3697b2f5810931d6c8acbf82a7c..32a62ea6011acf1c3d3340868ef6dcc82e6f7ca7 100644 --- a/src/migration/db.sql +++ b/src/migration/db.sql @@ -1,11 +1,27 @@ -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role') THEN - CREATE TYPE role AS ENUM ('BASIC', 'ADMIN'); - END IF; -END -$$; - +DO $$ BEGIN IF NOT EXISTS ( + SELECT 1 + FROM pg_type + WHERE typname = 'role' +) THEN CREATE TYPE role AS ENUM ('BASIC', 'ADMIN'); +END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN IF NOT EXISTS ( + SELECT 1 + FROM pg_type + WHERE typname = 'category' +) THEN CREATE TYPE category AS ENUM ('ANIME', 'DRAMA', 'MIXED'); +END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN IF NOT EXISTS ( + SELECT 1 + FROM pg_type + WHERE typname = 'visibility' +) THEN CREATE TYPE visibility AS ENUM ('PRIVATE', 'PUBLIC'); +END IF; +END $$; +--> statement-breakpoint CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, uuid VARCHAR(36) NOT NULL UNIQUE, @@ -13,29 +29,19 @@ CREATE TABLE IF NOT EXISTS users ( password VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, role role DEFAULT 'BASIC' NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL ); - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS sessions ( id VARCHAR(16) PRIMARY KEY, user_id INT NOT NULL, expired TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ); - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'category') THEN - CREATE TYPE category AS ENUM ('ANIME', 'DRAMA', 'MIXED'); - END IF; -END -$$; - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS catalogs ( id SERIAL PRIMARY KEY, uuid VARCHAR(36) NOT NULL UNIQUE, @@ -44,37 +50,11 @@ CREATE TABLE IF NOT EXISTS catalogs ( poster VARCHAR(255) NOT NULL, trailer VARCHAR(255), category category NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - CHECK (category IN ('ANIME', 'DRAMA')) ); - -CREATE OR REPLACE FUNCTION updated_at() -RETURNS TRIGGER -LANGUAGE PLPGSQL -AS $$ -BEGIN - NEW.updated_at = NOW(); - return NEW; -END; -$$; - -CREATE OR REPLACE TRIGGER t_user_updated_at BEFORE UPDATE -ON users FOR EACH ROW EXECUTE PROCEDURE updated_at(); - -CREATE OR REPLACE TRIGGER t_catalog_updated_at BEFORE UPDATE -ON catalogs FOR EACH ROW EXECUTE PROCEDURE updated_at(); - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'visibility') THEN - CREATE TYPE visibility AS ENUM ('PRIVATE', 'PUBLIC'); - END IF; -END -$$; - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS watchlists ( id SERIAL NOT NULL PRIMARY KEY, uuid VARCHAR(36) NOT NULL UNIQUE, @@ -85,147 +65,157 @@ CREATE TABLE IF NOT EXISTS watchlists ( item_count integer DEFAULT 0 NOT NULL, like_count integer DEFAULT 0 NOT NULL, visibility visibility DEFAULT 'PRIVATE' NOT NULL, - created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); - -CREATE OR REPLACE TRIGGER t_watchlist_updated_at BEFORE UPDATE -ON watchlists FOR EACH ROW EXECUTE PROCEDURE updated_at(); - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS watchlist_items( id SERIAL NOT NULL PRIMARY KEY, uuid VARCHAR(36) NOT NULL UNIQUE, - rank INT NOT NULL, description VARCHAR(255), watchlist_id integer NOT NULL, catalog_id integer NOT NULL, - created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, - FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE, FOREIGN KEY (catalog_id) REFERENCES catalogs(id) ON DELETE CASCADE ); - -CREATE OR REPLACE TRIGGER t_watchlist_items_updated_at BEFORE UPDATE -ON watchlists FOR EACH ROW EXECUTE PROCEDURE updated_at(); - -CREATE OR REPLACE FUNCTION watchlist_item_count() -RETURNS TRIGGER -LANGUAGE PLPGSQL -AS $$ -BEGIN - IF (TG_OP = 'INSERT') THEN - UPDATE watchlists SET item_count = item_count + 1 WHERE id = NEW.watchlist_id; - ELSIF (TG_OP = 'DELETE') THEN - UPDATE watchlists SET item_count = item_count - 1 WHERE id = OLD.watchlist_id; - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE TRIGGER t_watchlist_item_count AFTER INSERT OR DELETE -ON watchlist_items FOR EACH ROW EXECUTE PROCEDURE watchlist_item_count(); - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS watchlist_like ( id SERIAL NOT NULL PRIMARY KEY, user_id integer NOT NULL, watchlist_id integer NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE ); - - -CREATE OR REPLACE FUNCTION watchlist_like_count() -RETURNS TRIGGER -LANGUAGE PLPGSQL -AS $$ -BEGIN - IF (TG_OP = 'INSERT') THEN - UPDATE watchlists SET like_count = like_count + 1 WHERE id = NEW.watchlist_id; - ELSIF (TG_OP = 'DELETE') THEN - UPDATE watchlists SET like_count = like_count - 1 WHERE id = OLD.watchlist_id; - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE TRIGGER t_watchlist_like_count AFTER INSERT OR DELETE -ON watchlist_like FOR EACH ROW EXECUTE PROCEDURE watchlist_like_count(); - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS watchlist_save ( id SERIAL NOT NULL PRIMARY KEY, user_id integer NOT NULL, watchlist_id integer NOT NULL, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE ); - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS comments ( id SERIAL NOT NULL PRIMARY KEY, uuid VARCHAR(36) NOT NULL UNIQUE, content VARCHAR(255) NOT NULL, user_id integer NOT NULL, watchlist_id integer NOT NULL, - created_at timestamp DEFAULT now() NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE ); - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS tags ( id SERIAL PRIMARY KEY, name VARCHAR(40) NOT NULL UNIQUE, - created_at timestamp DEFAULT now() NOT NULL ); - +--> statement-breakpoint CREATE TABLE IF NOT EXISTS watchlist_tag ( watchlist_id integer NOT NULL, tag_id integer NOT NULL, - PRIMARY KEY (watchlist_id, tag_id), FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ); - -INSERT INTO users (uuid, name, password, email, role) VALUES ('72c5a265715fa26c', 'admin', '$2y$10$rAfDHA4M4ftn8K7Wx82wf.fFODD7PCE/t9CVnBwdLnTDBYjnq7ZnO', 'admin@drawl.com', 'ADMIN'); -INSERT INTO tags(name) VALUES ('ACTION'), - ('ADVENTURE'), - ('ANIMALS'), - ('BUSINESS'), - ('COMEDY'), - ('CRIME'), - ('DETECTIVE'), - ('DOCUMENTARY'), - ('DRAMA'), - ('FAMILY'), - ('FANTASY'), - ('FOOD'), - ('HISTORICAL'), - ('HORROR'), - ('LAW'), - ('LIFE'), - ('MANGA'), - ('MEDICAL'), - ('MATURE'), - ('MYSTERY'), - ('MUSIC'), - ('MILITARY'), - ('MELODRAMA'), - ('PSYCHOLOGICAL'), - ('ROMANCE'), - ('SCHOOL'), - ('SCI-FI'), - ('SPORTS'), - ('SUPERNATURAL'), - ('THRILLER'), - ('YOUTH') -; \ No newline at end of file +--> statement-breakpoint +CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER LANGUAGE PLPGSQL AS $$ BEGIN NEW.updated_at = NOW(); +return NEW; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION watchlist_item_count() RETURNS TRIGGER LANGUAGE PLPGSQL AS $$ BEGIN IF (TG_OP = 'INSERT') THEN +UPDATE watchlists +SET item_count = item_count + 1 +WHERE id = NEW.watchlist_id; +ELSIF (TG_OP = 'DELETE') THEN +UPDATE watchlists +SET item_count = item_count - 1 +WHERE id = OLD.watchlist_id; +END IF; +RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION watchlist_like_count() RETURNS TRIGGER LANGUAGE PLPGSQL AS $$ BEGIN IF (TG_OP = 'INSERT') THEN +UPDATE watchlists +SET like_count = like_count + 1 +WHERE id = NEW.watchlist_id; +ELSIF (TG_OP = 'DELETE') THEN +UPDATE watchlists +SET like_count = like_count - 1 +WHERE id = OLD.watchlist_id; +END IF; +RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE TRIGGER t_user_updated_at BEFORE +UPDATE ON users FOR EACH ROW EXECUTE PROCEDURE updated_at(); +--> statement-breakpoint +CREATE OR REPLACE TRIGGER t_catalog_updated_at BEFORE +UPDATE ON catalogs FOR EACH ROW EXECUTE PROCEDURE updated_at(); +--> statement-breakpoint +CREATE OR REPLACE TRIGGER t_watchlist_updated_at BEFORE +UPDATE ON watchlists FOR EACH ROW EXECUTE PROCEDURE updated_at(); +--> statement-breakpoint +CREATE OR REPLACE TRIGGER t_watchlist_items_updated_at BEFORE +UPDATE ON watchlists FOR EACH ROW EXECUTE PROCEDURE updated_at(); +--> statement-breakpoint +CREATE OR REPLACE TRIGGER t_watchlist_item_count +AFTER +INSERT + OR DELETE ON watchlist_items FOR EACH ROW EXECUTE PROCEDURE watchlist_item_count(); +--> statement-breakpoint +CREATE OR REPLACE TRIGGER t_watchlist_like_count +AFTER +INSERT + OR DELETE ON watchlist_like FOR EACH ROW EXECUTE PROCEDURE watchlist_like_count(); +--> statement-breakpoint +INSERT INTO users (uuid, name, password, email, role) +VALUES ( + '72c5a265715fa26c', + 'admin', + '$2y$10$rAfDHA4M4ftn8K7Wx82wf.fFODD7PCE/t9CVnBwdLnTDBYjnq7ZnO', + 'admin@drawl.com', + 'ADMIN' + ); +--> statement-breakpoint +INSERT INTO tags(name) +VALUES ('ACTION'), + ('ADVENTURE'), + ('ANIMALS'), + ('BUSINESS'), + ('COMEDY'), + ('CRIME'), + ('DETECTIVE'), + ('DOCUMENTARY'), + ('DRAMA'), + ('FAMILY'), + ('FANTASY'), + ('FOOD'), + ('HISTORICAL'), + ('HORROR'), + ('LAW'), + ('LIFE'), + ('MANGA'), + ('MEDICAL'), + ('MATURE'), + ('MYSTERY'), + ('MUSIC'), + ('MILITARY'), + ('MELODRAMA'), + ('PSYCHOLOGICAL'), + ('ROMANCE'), + ('SCHOOL'), + ('SCI-FI'), + ('SPORTS'), + ('SUPERNATURAL'), + ('THRILLER'), + ('YOUTH'); +--> statement-breakpoint \ No newline at end of file diff --git a/src/public/assets/images/catalogs/posters/188f1a368142857e.webp b/src/public/assets/images/catalogs/posters/188f1a368142857e.webp new file mode 100644 index 0000000000000000000000000000000000000000..b55ae97356eed638f243d72387578f4905229caf Binary files /dev/null and b/src/public/assets/images/catalogs/posters/188f1a368142857e.webp differ diff --git a/src/public/assets/images/catalogs/posters/no-poster.webp b/src/public/assets/images/catalogs/posters/no-poster.webp deleted file mode 100644 index f5a0f34071dd9e57d1c1ced894189082f60c6140..0000000000000000000000000000000000000000 Binary files a/src/public/assets/images/catalogs/posters/no-poster.webp and /dev/null differ diff --git a/src/public/assets/videos/catalogs/trailers/28eb5ae62ef05e3d.mp4 b/src/public/assets/videos/catalogs/trailers/28eb5ae62ef05e3d.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..0b2b5164c7e03f093a3a87dfbdcfed86cc6c18d0 Binary files /dev/null and b/src/public/assets/videos/catalogs/trailers/28eb5ae62ef05e3d.mp4 differ diff --git a/src/public/assets/videos/catalogs/trailers/e99e29043515d496.mp4 b/src/public/assets/videos/catalogs/trailers/e99e29043515d496.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..99c1c2430523e47ea2c8a065c65278533fc26d0a Binary files /dev/null and b/src/public/assets/videos/catalogs/trailers/e99e29043515d496.mp4 differ diff --git a/src/public/css/catalog.css b/src/public/css/catalog.css index e0a38b10e46d7b653deb5185d836ea94dfe22638..8e6d02cee41f1fa3fb0b80592fe7da9e0b46e8ae 100644 --- a/src/public/css/catalog.css +++ b/src/public/css/catalog.css @@ -1,3 +1,16 @@ +.search { + display: flex; + flex-direction: row; + align-items: center; + flex-grow: 1; + width: 100%; + gap: 1rem; +} + +.input-search { + flex-grow: 1; +} + form { display: flex; flex-wrap: wrap; diff --git a/src/public/js/catalog/createRequest.js b/src/public/js/catalog/createRequest.js new file mode 100644 index 0000000000000000000000000000000000000000..faaf3293697d7912ce7f9d90122a1064e0fe2edc --- /dev/null +++ b/src/public/js/catalog/createRequest.js @@ -0,0 +1,125 @@ +const CATEGORY = ["ANIME", "DRAMA"]; + +function validateInput(formData, update = false) { + if (!formData.get("title") || formData.get("title").trim() === "") { + return { + valid: false, + message: "Title is required.", + }; + } + if (formData.get("title").length > 40) { + return { + valid: false, + message: "Title is too long. Maximum 40 chars.", + }; + } + if (formData.get("description") && formData.get("description").length > 255) { + return { + valid: false, + message: "Description is too long. Maximum 255 chars.", + }; + } + if ( + !formData.get("category") || + !CATEGORY.includes(formData.get("category").trim()) + ) { + return { + valid: false, + message: "Category is invalid.", + }; + } + + if (!update && !formData.get("poster").name) { + return { + valid: false, + message: "Poster is required.", + }; + } + return { + valid: true, + }; +} + +function createCatalogReq(form) { + const apiUrl = `/api/v2/catalog-request`; + const formData = new FormData(form); + + const validate = validateInput(formData); + + if (!validate.valid) { + showToast("Error", validate.message, "error"); + return; + } + + const xhttp = new XMLHttpRequest(); + xhttp.open("POST", apiUrl, true); + + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + const response = JSON.parse(xhttp.responseText); + if (xhttp.status === 200 && response.status === 200) { + showToast("Success", `Request for catalog created`, "success"); + setTimeout(() => { + window.location.href = `/catalog`; + }, [1000]); + } else { + try { + showToast("Error", response.message); + } catch (e) { + showToast("Error", "Something went wrong", "error"); + } + } + } + }; + + xhttp.send(formData); +} + +const form = document.querySelector("form#catalog-create-update"); + +form.addEventListener("submit", function (event) { + event.preventDefault(); + + dialog( + "Request Catalog", + `Are you sure you want to request this catalog?`, + "request", + "request", + "Confirm", + () => { + createCatalogReq(form); + } + ); +}); + +posterImg = document.querySelector("img#poster"); +if (posterImg) { + posterInput = document.querySelector("input#posterField"); + posterInput.addEventListener("change", function (event) { + if (this.files[0]) { + var reader = new FileReader(); + + reader.readAsDataURL(this.files[0]); + + reader.onloadend = function () { + posterImg.src = reader.result; + }; + } + }); +} + +trailerVideo = document.querySelector("video#trailer"); +if (trailerVideo) { + trailerInput = document.querySelector("input#trailerField"); + trailerInput.addEventListener("change", function (event) { + if (this.files[0]) { + var reader = new FileReader(); + + reader.readAsDataURL(this.files[0]); + + reader.onloadend = function () { + trailerVideo.src = reader.result; + }; + } + }); +} diff --git a/src/public/js/catalog/createUpdate.js b/src/public/js/catalog/createUpdate.js index a9986031bc7d281aea32489c966bcb7491205dcc..178fcf88e58abce890951a4e5f27e21f3c69cd70 100644 --- a/src/public/js/catalog/createUpdate.js +++ b/src/public/js/catalog/createUpdate.js @@ -41,7 +41,7 @@ function validateInput(formData, update = false) { } function createCatalog(form) { - const apiUrl = `/api/catalog/create`; + const apiUrl = `/api/catalog`; const formData = new FormData(form); const validate = validateInput(formData); @@ -83,7 +83,7 @@ function updateCatalog(form) { const urlParts = window.location.pathname.split("/"); const uuidIndex = urlParts.indexOf("catalog") + 1; const uuid = urlParts[uuidIndex]; - const apiUrl = `/api/catalog/${uuid}/update`; + const apiUrl = `/api/catalog/${uuid}`; const formData = new FormData(form); const validate = validateInput(formData, true); diff --git a/src/public/js/catalog/delete.js b/src/public/js/catalog/delete.js index de2c8056536e030b53137e4ef05326d313a0b305..e8fbf561ee4522a322f5d96fcc9cb98f83da7c78 100644 --- a/src/public/js/catalog/delete.js +++ b/src/public/js/catalog/delete.js @@ -1,6 +1,6 @@ function deleteCatalog(uuid, title) { const xhttp = new XMLHttpRequest(); - xhttp.open("DELETE", `/api/catalog/${uuid}/delete`, true); + xhttp.open("DELETE", `/api/catalog/${uuid}`, true); xhttp.setRequestHeader("Content-Type", "application/json"); xhttp.onreadystatechange = function () { diff --git a/src/seed/seed.sql b/src/seed/seed.sql index 8b62583090dd7f402747b70f44c583abb704cafd..864e302c971010d65a5dd1eee9ebb924e8d24301 100644 --- a/src/seed/seed.sql +++ b/src/seed/seed.sql @@ -1,3 +1,23 @@ +--> statement-breakpoint +INSERT INTO users (uuid, name, password, email, role) +VALUES ( + '28f125abf7539b67', + 'berry good', + '$2y$10$rAfDHA4M4ftn8K7Wx82wf.fFODD7PCE/t9CVnBwdLnTDBYjnq7ZnO', + 'berrygood@gmail.com', + 'BASIC' + ); +--> statement-breakpoint +INSERT INTO catalogs (uuid, title, description, poster, trailer, category) +VALUES ( + '9511e980-c1ec-b3c8-53df-774bd4159aba', + 'DEFAULT CATALOG', + 'Hello', + '5a5ac4ad0c3a5e7c.webp', + 'a3e992b0d939a896.mp4', + 'ANIME' + ); + INSERT INTO catalogs (uuid, title, description, poster, trailer, category) SELECT md5(random()::text || clock_timestamp()::text)::uuid, @@ -17,3 +37,15 @@ SELECT 'a3e992b0d939a896.mp4', 'DRAMA' FROM generate_series(1, 100) AS num; + +-- Generate and insert 10,000 user records +INSERT INTO users (uuid, name, password, email, role, created_at, updated_at) +SELECT + uuid_generate_v4() AS uuid, + 'User' || gs AS name, + '$2y$10$rAfDHA4M4ftn8K7Wx82wf.fFODD7PCE/t9CVnBwdLnTDBYjnq7ZnO' AS password, -- Generating random password hashes + 'user' || gs || '@example.com' AS email, + 'BASIC' AS role, + NOW() - interval '1 year' * random() AS created_at, + NOW() - interval '1 year' * random() AS updated_at +FROM generate_series(1, 10000) gs; \ No newline at end of file diff --git a/src/server/.env.example b/src/server/.env.example index e22355013b0cb5006a33161d86fb2a915497536c..cef77833b8455b5dc1ddd0973e220814d8668ab5 100644 --- a/src/server/.env.example +++ b/src/server/.env.example @@ -1,5 +1,16 @@ DB_NAME= -DB_HOST=db +DB_HOST=phpServiceDB DB_PORT=5432 DB_USER= -DB_PASSWORD= \ No newline at end of file +DB_PASSWORD= + +# On WSL find it using `hostname -I` command +SPA_CLIENT_BASE_URL= +SOAP_SERVICE_BASE_URL= +REST_SERVICE_BASE_URL= + +# Max. 64-bit string hashed w/ bcrypt +# Generate here: https://generate-random.org/api-key-generator?count=1&length=64&type=mixed-numbers&prefix= +API_KEY= +OUTBOUND_REST_API_KEY= +OUTBOUND_SOAP_API_KEY= \ No newline at end of file diff --git a/src/server/app/Common/CustomResponse.php b/src/server/app/Common/CustomResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..e8799fba49ee5fca5974f4e81035036073393757 --- /dev/null +++ b/src/server/app/Common/CustomResponse.php @@ -0,0 +1,8 @@ +<?php + +class CustomResponse +{ + public int $status; + public string $message; + public $data; +} \ No newline at end of file diff --git a/src/server/app/Common/ValidationResult.php b/src/server/app/Common/ValidationResult.php new file mode 100644 index 0000000000000000000000000000000000000000..8a58d6898084c8203238c268c48cd4c1e2a3e03d --- /dev/null +++ b/src/server/app/Common/ValidationResult.php @@ -0,0 +1,7 @@ +<?php + +class ValidationResult +{ + public bool $success; + public string $message; +} diff --git a/src/server/app/Controller/CatalogController.php b/src/server/app/Controller/CatalogController.php index fca0b5899ec27f13fd25175a97ecb220540b3bbd..da348871e8d75747f893875ec44a32a2bb2c16a1 100644 --- a/src/server/app/Controller/CatalogController.php +++ b/src/server/app/Controller/CatalogController.php @@ -15,6 +15,12 @@ require_once __DIR__ . '/../Model/CatalogCreateRequest.php'; require_once __DIR__ . '/../Model/catalog/CatalogUpdateRequest.php'; require_once __DIR__ . '/../Model/CatalogSearchRequest.php'; +require_once __DIR__ . '/../Utils/SOAPRequest.php'; +require_once __DIR__ . '/../Utils/GetRequestHeader.php'; + +require_once __DIR__ . '/../Utils/UUIDGenerator.php'; +require_once __DIR__ . '/../Utils/FileUploader.php'; + class CatalogController { private CatalogService $catalogService; @@ -34,6 +40,7 @@ class CatalogController public function index(): void { + $search = $_GET['search'] ?? ""; $page = $_GET['page'] ?? 1; $category = $_GET['category'] ?? "MIXED"; @@ -45,10 +52,10 @@ class CatalogController '/css/catalog.css', ], 'js' => [ - '/js/catalog/delete.js' + '/js/catalog/delete.js', ], 'data' => [ - 'catalogs' => $this->catalogService->findAll($page, $category), + 'catalogs' => $this->catalogService->findAll($page, $category, $search), 'category' => strtoupper(trim($category)), 'userRole' => $user ? $user->role : null ] @@ -272,4 +279,132 @@ class CatalogController echo json_encode($response); } } -} \ No newline at end of file + + // V2 methods + + public function createCatalogFromRequest() + { + $json = file_get_contents('php://input'); + $data = json_decode($json); + + $request = new CatalogCreateRequest(); + if (isset($data->category)) { + $request->category = $data->category; + } + + $request->title = $data->title; + $request->description = $data->description; + + if (isset($data->poster)) { + $request->poster = $data->poster; + } + + if (isset($data->trailer)) { + $request->trailer = $data->trailer; + } + + try { + $response = $this->catalogService->createFromRequest($request); + http_response_code(200); + $response = [ + "status" => 200, + "message" => "Successfully created catalog", + "data" => [ + "uuid" => $response->catalog->uuid, + "title" => $response->catalog->title, + ] + ]; + + echo json_encode($response); + } catch (ValidationException $exception) { + http_response_code(400); + $response = [ + "status" => 400, + "message" => $exception->getMessage(), + ]; + + echo json_encode($response); + } catch (\Exception $exception) { + http_response_code(500); + $response = [ + "status" => 500, + "message" => "Something went wrong.", + ]; + + echo json_encode($response); + } + } + + // public function getCatalogRequest() + // { + // $json = file_get_contents('php://input'); + // $data = json_decode($json); + // $token = GetRequestHeader::getHeader("token", 1); + + // $page = $data->page ?? ""; + // $pagesize = $data->pagesize ?? ""; + + + // $headers = array("token:{$token}"); + // $body = [ + // "page" => $page, + // "pagesize" => $pagesize, + // ]; + + // $soapRequest = new SOAPRequest("catalog-request", "GetCatalog", $headers, [], $body); + // $response = $soapRequest->post(); + + // echo json_encode($response); + // } + + // public function catalogRequestCallback() + // { + // $json = file_get_contents('php://input'); + // $data = json_decode($json); + + // $response = new CustomResponse(); + // $response->status = 200; + // $response->message = 'Success'; + // $response->data = $data; + + // echo json_encode($response); + // } + + public function getCatalogs() + { + $title = $_GET["title"] ?? ""; + $page = $_GET["page"] ?? "1"; + $amount = $_GET["amount"] ?? "10"; + + + $catalogSearchRequest = new CatalogSearchRequest(); + $catalogSearchRequest->title = $title; + $catalogSearchRequest->page = $page; + $catalogSearchRequest->pageSize = $amount; + + $catalogs = $this->catalogService->search($catalogSearchRequest); + + $response = new CustomResponse(); + $response->status = 200; + $response->message = "Success"; + $response->data = $catalogs->catalogs; + + echo json_encode($response); + } + + public function getCatalogByUUID(string $uuid) + { + $catalog = $this->catalogService->findByUUID($uuid); + + $response = new CustomResponse(); + $response->status = 200; + $response->message = "Success"; + $response->data = [ + "title" => $catalog->title, + "description" => $catalog->description, + "poster" => $catalog->poster + ]; + + echo json_encode($response); + } +} diff --git a/src/server/app/Controller/CatalogRequestController.php b/src/server/app/Controller/CatalogRequestController.php new file mode 100644 index 0000000000000000000000000000000000000000..753974b981829359f9fb2021d60a7ef9781ab1e2 --- /dev/null +++ b/src/server/app/Controller/CatalogRequestController.php @@ -0,0 +1,106 @@ +<?php + +require_once __DIR__ . '/../App/View.php'; +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../Exception/ValidationException.php'; + +require_once __DIR__ . '/../Service/CatalogService.php'; +require_once __DIR__ . '/../Service/SessionService.php'; + +require_once __DIR__ . '/../Repository/CatalogRepository.php'; +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +require_once __DIR__ . '/../Model/CatalogCreateRequest.php'; +require_once __DIR__ . '/../Model/catalog/CatalogUpdateRequest.php'; +require_once __DIR__ . '/../Model/CatalogSearchRequest.php'; + +require_once __DIR__ . '/../Utils/SOAPRequest.php'; +require_once __DIR__ . '/../Utils/GetRequestHeader.php'; + +require_once __DIR__ . '/../Utils/UUIDGenerator.php'; +require_once __DIR__ . '/../Utils/FileUploader.php'; + +class CatalogRequestController +{ + private CatalogService $catalogService; + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + $catalogRepository = new CatalogRepository($connection); + $this->catalogService = new CatalogService($catalogRepository); + $sessionRepository = new SessionRepository($connection); + $userRepository = new UserRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + public function request(): void + { + View::render('catalog/form', [ + 'title' => 'Request Catalog', + 'styles' => [ + '/css/catalog-form.css', + ], + 'js' => [ + '/js/catalog/createRequest.js' + ], + 'type' => 'create' + ], $this->sessionService); + } + + public function create() + { + $posterUploader = new FileUploader('Poster', 'assets/images/catalogs/posters/'); + $trailerUploader = new FileUploader('Trailer', 'assets/videos/catalogs/trailers/'); + $trailerUploader->allowedExtTypes = ["mp4"]; + $trailerUploader->allowedMimeTypes = ["video/mp4"]; + $trailerUploader->maxFileSize = 100000000; + + if (isset($_FILES['poster']) && $_FILES['poster']['error'] == UPLOAD_ERR_OK) { + $postername = $posterUploader->uploadFie($_FILES['poster'], $_POST['title']); + } + + if (isset($_FILES['trailer']) && $_FILES['trailer']['error'] == UPLOAD_ERR_OK) { + $trailername = $trailerUploader->uploadFie($_FILES['trailer'], $_POST['title']); + } + + $body = [ + "uuid" => UUIDGenerator::uuid4(), + "title" => isset($_POST['title']) ? $_POST['title'] : "", + "description" => isset($_POST['description']) ? $_POST['description'] : "", + "poster" => isset($postername) ? $postername : "", + "trailer" => isset($trailername) ? $trailername : "", + "category" => isset($_POST['category']) ? $_POST['category'] : "ANIME", + ]; + + + $soapRequest = new SOAPRequest("catalog-request", "CatalogCreateRequest", [], [], $body); + $response = $soapRequest->post(); + echo json_encode($response); + } + + public function deletePoster($poster) + { + if (isset($poster)) { + unlink($_SERVER['DOCUMENT_ROOT'] . '/assets/images/catalogs/posters/' . $poster); + } + + echo json_encode([ + "status" => 200, + "message" => "Successfully delete catalog request", + ]); + } + + public function deleteTrailer($trailer) + { + if (isset($trailer)) { + unlink($_SERVER['DOCUMENT_ROOT'] . '/assets/videos/catalogs/trailers/' . $trailer); + } + + echo json_encode([ + "status" => 200, + "message" => "Successfully delete catalog request", + ]); + } +} \ No newline at end of file diff --git a/src/server/app/Controller/UserController.php b/src/server/app/Controller/UserController.php index 54a9f7361f2e000b3a8428d449690ca104ac3609..8bf380345a8e672a2b0c838e45410b98ddb98291 100644 --- a/src/server/app/Controller/UserController.php +++ b/src/server/app/Controller/UserController.php @@ -14,6 +14,8 @@ require_once __DIR__ . '/../Model/UserSignUpRequest.php'; require_once __DIR__ . '/../Model/UserSignInRequest.php'; require_once __DIR__ . '/../Model/UserEditRequest.php'; require_once __DIR__ . '/../Model/session/SessionCreateRequest.php'; +require_once __DIR__ . '/../Model/user/SignInV2Request.php'; +require_once __DIR__ . '/../Model/user/GetUserInfoRequest.php'; class UserController { @@ -32,8 +34,11 @@ class UserController public function signUp(): void { + $redirectTo = $_GET['redirect_to'] ?? null; + View::render('user/signUp', [ 'title' => 'Sign Up', + 'redirectTo' => $redirectTo, 'styles' => [ '/css/signUp.css', ], @@ -72,17 +77,25 @@ class UserController $request->password = $_POST['password']; $request->confirmPassword = $_POST['passwordConfirm']; + $redirectTo = $_GET["redirect_to"] ?? null; + try { $this->userService->signUp($request); - View::redirect('/signin'); + View::redirect($redirectTo && $redirectTo != '' ? $redirectTo : '/signin'); } catch (ValidationException $exception) { View::render('user/signUp', [ 'title' => 'Sign Up', 'error' => $exception->getMessage(), + 'redirectTo' => $redirectTo, 'styles' => [ '/css/signUp.css', ], + 'data' => [ + 'email' => $request->email, + 'password' => $request->password, + 'confirmPassword' => $request->confirmPassword + ] ], $this->sessionService); } } @@ -247,39 +260,64 @@ class UserController public function delete(): void { $currentUser = $this->sessionService->current(); + $response = new CustomResponse(); try { - if (!$currentUser) { throw new ValidationException("Unauthorized.", 401); } - $this->userService->deleteBySession($currentUser->email); - $this->userService->deleteByEmail($currentUser->email); - http_response_code(200); - $response = [ - "status" => 200, - "message" => "Successfully delete user", - ]; + $this->userService->deleteUser($currentUser->email, $currentUser->uuid); + + http_response_code(200); + $response->status = 200; + $response->message = "User successfully deleted"; - echo json_encode($response); } catch (ValidationException $exception) { http_response_code($exception->getCode() ?? 400); - $response = [ - "status" => $exception->getCode() ?? 400, - "message" => $exception->getMessage(), - ]; - - echo json_encode($response); + $response->status = $exception->getCode() ?? 400; + $response->message = $exception->getMessage(); } catch (\Exception $exception) { - http_response_code(500); - $response = [ - "status" => 500, - "message" => "Something went wrong.", - ]; + if ($exception->getCode() == 500) { + http_response_code($exception->getCode()); - echo json_encode($response); + $response->status = $exception->getCode(); + $response->message = $exception->getMessage(); + } + + http_response_code(500); + $response->status = $exception->getCode(); + $response->message = $exception->getMessage(); } + + echo json_encode($response); + } + + // V2 Methods + public function signInV2(): void + { + $json = file_get_contents('php://input'); + $data = json_decode($json); + + $request = new SignInV2Request(); + $request->email = $data->email ?? ""; + $request->password = $data->password ?? ""; + + $response = $this->userService->signInV2($request); + + echo json_encode($response); + } + + public function getUserInfo(): void + { + $userId = $_GET["userId"] ?? null; + + $request = new GetUserInfoRequest(); + $request->userId = $userId; + + $response = $this->userService->getUserInfo($request); + + echo json_encode($response); } } \ No newline at end of file diff --git a/src/server/app/Middleware/AuthPageMiddleware.php b/src/server/app/Middleware/AuthPageMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..922dba564859d7faca428f5d9b14bba877e1b39f --- /dev/null +++ b/src/server/app/Middleware/AuthPageMiddleware.php @@ -0,0 +1,31 @@ +<?php + +require_once __DIR__ . '/../App/Middleware.php'; +require_once __DIR__ . '/../Service/SessionService.php'; +require_once __DIR__ . '/../Config/Database.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +class AuthPageMiddleware implements Middleware +{ + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + + $userRepository = new UserRepository($connection); + $sessionRepository = new SessionRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function run(): void + { + $user = $this->sessionService->current(); + if ($user != null) { + header("Location: /"); + exit(); + } + } +} diff --git a/src/server/app/Middleware/ExtUserAuthMiddleware.php b/src/server/app/Middleware/ExtUserAuthMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..99c4ff9d53a04024d294b66eaf46dfcfada5c9d9 --- /dev/null +++ b/src/server/app/Middleware/ExtUserAuthMiddleware.php @@ -0,0 +1,32 @@ +<?php + +require_once __DIR__ . '/../Common/CustomResponse.php'; + +class ExtUserAuthMiddleware +{ + public function __construct() + { + } + + public function run(): void + { + $headers = getallheaders(); + $apiKey = ""; + foreach ($headers as $headerName => $headerValue) { + if (strtolower($headerName) === 'authorization') { + $apiKey = explode(' ', $headerValue)[1]; + } + } + + if (!password_verify($apiKey, getenv('API_KEY'))) { + http_response_code(401); + $response = new CustomResponse(); + $response->status = 401; + $response->message = "Unauthorized"; + $response->data = $apiKey; + + echo json_encode($response); + exit(); + } + } +} \ No newline at end of file diff --git a/src/server/app/Model/user/GetUserInfoRequest.php b/src/server/app/Model/user/GetUserInfoRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..957609f990ead8e167218aca2ffc93478579eb61 --- /dev/null +++ b/src/server/app/Model/user/GetUserInfoRequest.php @@ -0,0 +1,6 @@ +<?php + +class GetUserInfoRequest +{ + public ?string $userId; +} \ No newline at end of file diff --git a/src/server/app/Model/user/SignInV2Request.php b/src/server/app/Model/user/SignInV2Request.php new file mode 100644 index 0000000000000000000000000000000000000000..416ae77dabfea8a585c3395755a1f5703ec034ad --- /dev/null +++ b/src/server/app/Model/user/SignInV2Request.php @@ -0,0 +1,7 @@ +<?php + +class SignInV2Request +{ + public ?string $email; + public ?string $password; +} \ No newline at end of file diff --git a/src/server/app/Service/CatalogService.php b/src/server/app/Service/CatalogService.php index f340805663344e6e679335c39b987cb5feb592ff..03fe243f5b6bc1b004d580ad6edd84fcca24a6c2 100644 --- a/src/server/app/Service/CatalogService.php +++ b/src/server/app/Service/CatalogService.php @@ -29,12 +29,14 @@ class CatalogService $this->trailerUploader->maxFileSize = 100000000; } - public function findAll(int $page = 1, string $category = "MIXED"): array + public function findAll(int $page = 1, string $category = "MIXED", string $search = ""): array { $query = $this->catalogRepository->query(); if ($category != "MIXED") { $category = strtoupper(trim($category)); - $query = $query->whereEquals('category', $category); + $query = $query->whereEquals('category', $category)->andWhereContains('title', $search); + } else { + $query = $query->whereContains('title', $search); } $projection = ['id', 'uuid', 'title', 'category', 'description', 'poster']; $catalogs = $query->get($projection, $page, 10); @@ -147,11 +149,17 @@ class CatalogService if ($request->poster && $request->poster['error'] == UPLOAD_ERR_OK) { $postername = $this->posterUploader->uploadFie($request->poster, $catalog->title); + if ($catalog->poster != null) { + unlink($_SERVER['DOCUMENT_ROOT'] . '/assets/images/catalogs/posters/' . $catalog->poster); + } $catalog->poster = $postername; } if ($request->trailer && $request->trailer['error'] == UPLOAD_ERR_OK) { $trailername = $this->trailerUploader->uploadFie($request->trailer, $catalog->title); + if ($catalog->trailer != null) { + unlink($_SERVER['DOCUMENT_ROOT'] . '/assets/videos/catalogs/trailers/' . $catalog->trailer); + } $catalog->trailer = $trailername; } @@ -171,6 +179,68 @@ class CatalogService } } + public function createFromRequest(CatalogCreateRequest $request) + { + $this->validateCatalogCreateFromRequest($request); + + try { + Database::beginTransaction(); + + $catalog = new Catalog(); + + $catalog->uuid = UUIDGenerator::uuid4(); + $catalog->title = trim($request->title); + $catalog->description = trim($request->description); + + $catalog->poster = $request->poster; + $catalog->trailer = $request->trailer; + $catalog->category = strtoupper(trim($request->category)); + + $this->catalogRepository->save($catalog); + + $response = new CatalogCreateResponse(); + $response->catalog = $catalog; + + Database::commitTransaction(); + return $response; + } catch (FileUploaderException $exception) { + Database::rollbackTransaction(); + throw new ValidationException($exception->getMessage()); + } catch (\Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } + + private function validateCatalogCreateFromRequest(CatalogCreateRequest $request) + { + if ( + $request->title == null || trim($request->title) == "" + ) { + throw new ValidationException("Title cannot be blank."); + } + + if (strlen($request->title) > 40) { + throw new ValidationException("Title cannot be more than 40 characters."); + } + + if (strlen($request->description) > 255) { + throw new ValidationException("Description cannot be more than 255 characters."); + } + + if ($request->category == null || trim($request->category) == "") { + throw new ValidationException("Category cannot be blank."); + } + + if ($request->category != "ANIME" && $request->category != "DRAMA") { + throw new ValidationException("Category must be either ANIME or DRAMA."); + } + + if ($request->poster == null || trim($request->poster) == "") { + throw new ValidationException("Poster cannot be blank."); + } + } + private function validateCatalogUpdateRequest(CatalogUpdateRequest $request) { if ($request->uuid == null || trim($request->uuid) == "") { diff --git a/src/server/app/Service/UserService.php b/src/server/app/Service/UserService.php index f3f768014bead8cdcd6e7dcc393e3819b7939d40..6bf9c628c1e995525549859195f4ff3862f864ff 100644 --- a/src/server/app/Service/UserService.php +++ b/src/server/app/Service/UserService.php @@ -12,6 +12,9 @@ require_once __DIR__ . '/../Model/UserSignInResponse.php'; require_once __DIR__ . '/../Model/UserEditRequest.php'; require_once __DIR__ . '/../Model/UserEditResponse.php'; +require_once __DIR__ . '/../Common/ValidationResult.php'; +require_once __DIR__ . '/../Common/CustomResponse.php'; + class UserService { @@ -117,6 +120,14 @@ class UserService throw new ValidationException("Email, password, and confirm password cannot be blank."); } + if (strlen($request->password) < 8) { + throw new ValidationException("Password is too short, minimum 8 characters"); + } + + if (strlen($request->password) > 255) { + throw new ValidationException("Password is too long, maximum 255 characters"); + } + if ($request->password != $request->confirmPassword) { throw new ValidationException("Make sure both passwords are typed the same."); } @@ -199,4 +210,166 @@ class UserService { $this->userRepository->deleteBySession($email); } + + + // V2 Methods + public function signInV2(SignInV2Request $request): CustomResponse + { + $response = new CustomResponse(); + + $validateResult = $this->validateSignInV2($request); + if (!$validateResult->success) { + $response->status = 400; + $response->message = $validateResult->message; + + http_response_code($response->status); + + return $response; + } + + $user = $this->userRepository->findOne('email', $request->email); + if ($user == null) { + $response->status = 400; + $response->message = "Invalid email or password"; + + http_response_code($response->status); + + return $response; + } + + if (!password_verify($request->password, $user->password)) { + $response->status = 400; + $response->message = "Invalid email or password"; + + http_response_code($response->status); + + return $response; + } + + $response->status = 200; + $response->message = "Sign In Success"; + $response->data = [ + "uuid" => $user->uuid, + "email" => $user->email, + "name" => $user->name, + "role" => $user->role, + ]; + + http_response_code($response->status); + + return $response; + } + + public function getUserInfo(GetUserInfoRequest $request): CustomResponse + { + $response = new CustomResponse(); + + $validateResult = $this->validateGetUserInfo($request); + if (!$validateResult->success) { + $response->status = 400; + $response->message = $validateResult->message; + + http_response_code($response->status); + + return $response; + } + + $user = $this->userRepository->findOne('uuid', $request->userId); + + if ($user == null) { + $response->status = 404; + $response->message = "User not found"; + + http_response_code($response->status); + + return $response; + } + + $response->status = 200; + $response->message = "Success"; + $response->data = [ + "uuid" => $user->uuid, + "email" => $user->email, + "name" => $user->name, + "role" => $user->role, + ]; + + http_response_code($response->status); + + return $response; + } + + private function validateSignInV2(SignInV2Request $request): ValidationResult + { + $result = new ValidationResult(); + if ($request->email == null || trim($request->email) == "") { + $result->success = false; + $result->message = "Email is required"; + return $result; + } + if ($request->password == null || trim($request->password) == "") { + $result->success = false; + $result->message = "Password is required"; + return $result; + } + + $result->success = true; + $result->message = ""; + + return $result; + } + + private function validateGetUserInfo(GetUserInfoRequest $request): ValidationResult + { + $result = new ValidationResult(); + + if ($request->userId == null || trim($request->userId) == "") { + $result->success = false; + $result->message = "User ID is required"; + return $result; + } + + $result->success = true; + $result->message = ""; + + return $result; + } + + public function deleteUser(string $email, string $uuid) + { + try { + Database::beginTransaction(); + $this->deleteBySession($email); + $this->deleteByEmail($email); + + // delete profile on REST service + $baseUrl = getenv('REST_SERVICE_BASE_URL'); + $url = "{$baseUrl}/profile/{$uuid}"; + $curl = curl_init($url); + $httpHeaders = array( + "api_key: " . getenv('OUTBOUND_REST_API_KEY'), + ); + curl_setopt($curl, CURLOPT_URL, $url); + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, $httpHeaders); + + $response = curl_exec($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if (!$response) { + throw new Exception("Something went wrong, please try again later", 500); + } + + if ($httpCode != "200") { + throw new Exception("Something went wrong, please try again later", 500); + } + + Database::commitTransaction(); + } catch (Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } } \ No newline at end of file diff --git a/src/server/app/Utils/GetRequestHeader.php b/src/server/app/Utils/GetRequestHeader.php new file mode 100644 index 0000000000000000000000000000000000000000..45edf1c58986f87d18a21c48dc2cf1b929fb6525 --- /dev/null +++ b/src/server/app/Utils/GetRequestHeader.php @@ -0,0 +1,17 @@ +<?php + +class GetRequestHeader +{ + public static function getHeader(string $name, int $position): string + { + $headers = getallheaders(); + $header = ""; + foreach ($headers as $headerName => $headerValue) { + if (strtolower($headerName) === $name) { + $header = explode(' ', $headerValue)[$position - 1]; + } + } + + return $header; + } +} \ No newline at end of file diff --git a/src/server/app/Utils/SOAPRequest.php b/src/server/app/Utils/SOAPRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..393504c724dabb5e0a0ee7f1dfdf7e3e7a381257 --- /dev/null +++ b/src/server/app/Utils/SOAPRequest.php @@ -0,0 +1,96 @@ +<?php + +require_once __DIR__ . '/../Common/CustomResponse.php'; + +require_once __DIR__ . '/../Utils/SOAPResponse.php'; + +class SOAPRequest +{ + public string $url; + private string $endpoint; + private array $headers; + private string $operationName; + private array $soapHeaders; + private array $soapBody; + private string $serviceName; + + public function __construct($endpoint, $operationName, $headers, $soapHeaders, $soapBody, $serviceName = "http://Services.soapService.org/") + { + $this->url = (getenv("SOAP_SERVICE_BASE_URL") ?? "http://host.docker.internal:8083") . "/" . $endpoint; + $this->endpoint = $endpoint; + $this->operationName = $operationName; + $this->headers = $headers; + $this->soapHeaders = $soapHeaders; + $this->soapBody = $soapBody; + $this->serviceName = $serviceName; + } + + public function post(): CustomResponse + { + $curl = curl_init($this->url); + + $httpHeaders = array_merge( + array( + "Content-Type: text/xml", + "token:" . getenv("OUTBOUND_SOAP_API_KEY") + ), + ); + $body = <<<BODY + <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ser="{$this->serviceName}"> + {$this->parseHeader()} + {$this->parseBody()} + </soapenv:Envelope> + BODY; + + curl_setopt($curl, CURLOPT_URL, $this->url); + curl_setopt($curl, CURLOPT_POST, true); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, $httpHeaders); + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); + $response = curl_exec($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + if (!$response) { + $finalResponse = new CustomResponse(); + http_response_code(500); + $finalResponse->status = 500; + $finalResponse->message = "Something went wrong, please try again later"; + curl_close($curl); + return $finalResponse; + } + + curl_close($curl); + if ($httpCode == "500") { + return SOAPResponse::parseFault($response); + } else { + return SOAPResponse::parseSuccess($response); + } + } + + private function parseHeader(): string + { + $xml = new DOMDocument(); + $root = $xml->appendChild($xml->createElement("soapenv:Header")); + + foreach ($this->soapHeaders as $key => $value) { + $root->appendChild($xml->createElement($key, $value)); + } + + return $xml->saveXML($xml->documentElement); + } + + private function parseBody(): string + { + $xml = new DOMDocument(); + $root = $xml->appendChild($xml->createElement("soapenv:Body")); + $operation = $root->appendChild($xml->createElement("ser:{$this->operationName}")); + + foreach ($this->soapBody as $key => $value) { + $operation->appendChild($xml->createElement($key, $value)); + } + + return $xml->saveXML($xml->documentElement); + } +} \ No newline at end of file diff --git a/src/server/app/Utils/SOAPResponse.php b/src/server/app/Utils/SOAPResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..4c8000d96853a4601f1cdf8b9ba9a2a9864b6daf --- /dev/null +++ b/src/server/app/Utils/SOAPResponse.php @@ -0,0 +1,43 @@ +<?php + + +require_once __DIR__ . '/../Common/CustomResponse.php'; + +class SOAPResponse +{ + public static function parseFault(string $xml): CustomResponse + { + $p = xml_parser_create(); + xml_parse_into_struct($p, $xml, $vals, $index); + xml_parser_free($p); + + $response = new CustomResponse(); + $response->status = (int) $vals[$index["FAULTCODE"][0]]["value"]; + $response->message = $vals[$index["FAULTSTRING"][0]]["value"]; + + http_response_code($response->status); + return $response; + } + + public static function parseSuccess(string $xml): CustomResponse + { + $p = xml_parser_create(); + xml_parse_into_struct($p, $xml, $vals, $index); + xml_parser_free($p); + + $response = new CustomResponse(); + $response->status = (int) $vals[$index["STATUS"][0]]["value"]; + $response->message = $vals[$index["MESSAGE"][0]]["value"]; + $response->data = []; + + for ($i = 0; $i < count($index["DATA"]); $i += 2) { + $temp = []; + for ($j = $index["DATA"][$i] + 1; $j < $index["DATA"][$i + 1]; $j++) { + $temp[strtolower($vals[$j]["tag"])] = isset($vals[$j]["value"]) ? $vals[$j]["value"] : ""; + } + $response->data[] = $temp; + } + + return $response; + } +} \ No newline at end of file diff --git a/src/server/app/View/catalog/index.php b/src/server/app/View/catalog/index.php index a6df824829344d67024db762795792956b11baeb..94f13ad522e6ba946c03ef6dce151d1a756c2ee9 100644 --- a/src/server/app/View/catalog/index.php +++ b/src/server/app/View/catalog/index.php @@ -31,6 +31,11 @@ function pagination(int $currentPage, int $totalPage) <main> <section class="search-filter"> <form action="/catalog"> + <div class="search"> + <?php require PUBLIC_PATH . 'assets/icons/search.php'; ?> + <input type="text" name="search" placeholder="Search title" class="input-default input-search" + value="<?= trim($_GET['search'] ?? '') ?? '' ?>" /> + </div> <div class="input"> <label>Category</label> <?php selectCategory($model['data']['category'] ?? ""); ?> @@ -47,6 +52,14 @@ function pagination(int $currentPage, int $totalPage) Add Catalog </a> <?php endif; ?> + <?php if ($model['data']['userRole'] && $model['data']['userRole'] !== "ADMIN"): ?> + <a href="/catalog-request" class="btn btn-bold"> + <span class="icon-new"> + <?php require PUBLIC_PATH . 'assets/icons/plus.php' ?> + </span> + Request Catalog + </a> + <?php endif; ?> </section> <?php if (count($model['data']['catalogs']['items']) == 0): ?> <div class="no-item__container"> @@ -57,7 +70,7 @@ function pagination(int $currentPage, int $totalPage) </div> </div> <?php endif; ?> - <section class="content"> + <section class="content list__catalog"> <?php foreach ($model['data']['catalogs']['items'] ?? [] as $catalog): ?> <?php catalogCard($catalog, $model['data']['userRole'] && $model['data']['userRole'] === "ADMIN"); ?> <?php endforeach; ?> diff --git a/src/server/app/View/user/signUp.php b/src/server/app/View/user/signUp.php index a9e507ea5650688a549ca6ccd404e140312a2e45..60445bfc3680a4ab7c860ce83b97e69b0d6a68f5 100644 --- a/src/server/app/View/user/signUp.php +++ b/src/server/app/View/user/signUp.php @@ -8,7 +8,7 @@ function alert($title, $message) ?> <div class="signup-container row"> - <img src="/assets/images/Tomorrow.webp" alt="Sign Up Image" class="signup-poster" /> + <img src="/assets/images/Tomorrow.webp" alt="Sign Up Image" class="signup-poster"/> <div class="right-side"> <div class="main-container"> <div class="welcome-text"> @@ -20,26 +20,28 @@ function alert($title, $message) <?php alert('Failed to Sign up', $model['error']); ?> <?php endif; ?> - <form class="inputs" action="/signup" method="post"> + <form class="inputs" action=<?= "/signup?redirect_to=" . $model["redirectTo"] ?> method="post"> <div class="parameter"> <label for="email" class="input-required">Email</label> <input type="email" name="email" id="email" class="input-default" required - placeholder="Enter email"> + placeholder="Enter email" value=<?= $model["data"]["email"] ?? "" ?>> </div> <div class="parameter"> <label for="password" class="input-required">Password</label> <input type="password" name="password" id="password" class="input-default" required - placeholder="Enter password" /> + placeholder="Enter password" value=<?= $model["data"]["password"] ?? "" ?>> </div> <div class="parameter"> <label for="passwordConfirm" class="input-required">Confirm Password</label> <input type="password" name="passwordConfirm" id="passwordConfirm" class="input-default" required - placeholder="Enter confirm password" /> + placeholder="Enter confirm password" value=<?= $model["data"]["confirmPassword"] ?? "" ?>> </div> <button class="btn-bold" type="submit"> Sign Up </button> - <p>Already have an account? <a href="/signin" class="signin-link">Sign in</a></p> + <p>Already have an account? <a href="/signin" class="signin-link">Sign in to Drawl</a> or <a + href="<?= getenv("SPA_CLIENT_BASE_URL") . '/auth/login' ?>" class="signin-link">Sign in to + DQ</a></p> </form> </div> </div> diff --git a/src/server/routes/view.php b/src/server/routes/view.php index 850d114035dbdc88c0174227ae6e11552fc51d30..bea3b2cda0125f5995537e07fa42630552d8718e 100644 --- a/src/server/routes/view.php +++ b/src/server/routes/view.php @@ -7,11 +7,14 @@ require_once __DIR__ . "/../app/Controller/CatalogController.php"; require_once __DIR__ . '/../app/Controller/WatchlistController.php'; require_once __DIR__ . '/../app/Controller/ErrorPageController.php'; require_once __DIR__ . '/../app/Controller/BookmarkController.php'; +require_once __DIR__ . "/../app/Controller/CatalogRequestController.php"; require_once __DIR__ . '/../app/Middleware/UserAuthMiddleware.php'; +require_once __DIR__ . '/../app/Middleware/AuthPageMiddleware.php'; require_once __DIR__ . '/../app/Middleware/AdminAuthMiddleware.php'; require_once __DIR__ . '/../app/Middleware/UserAuthApiMiddleware.php'; require_once __DIR__ . '/../app/Middleware/AdminAuthApiMiddleware.php'; +require_once __DIR__ . '/../app/Middleware/ExtUserAuthMiddleware.php'; // Register routes @@ -20,23 +23,39 @@ Router::add('GET', '/', HomeController::class, 'index', []); Router::add("GET", "/api/watchlists", HomeController::class, 'watchlists', []); // User controllers -Router::add('GET', '/signup', UserController::class, 'signUp', []); +Router::add('GET', '/signup', UserController::class, 'signUp', [AuthPageMiddleware::class]); Router::add('POST', '/signup', UserController::class, 'postSignUp', []); -Router::add('GET', '/signin', UserController::class, 'signIn', []); +Router::add('GET', '/signin', UserController::class, 'signIn', [AuthPageMiddleware::class]); Router::add('POST', '/signin', UserController::class, 'postSignIn', []); + Router::add('POST', '/api/auth/logout', UserController::class, 'logout', [UserAuthMiddleware::class]); -Router::add('DELETE', '/api/auth/delete', UserController::class, 'delete', [UserAuthMiddleware::class]); +Router::add('DELETE', '/api/auth/delete', UserController::class, 'delete', []); Router::add('PUT', '/api/auth/update', UserController::class, 'update', [UserAuthMiddleware::class]); +Router::add('POST', '/api/v2/auth/signin', UserController::class, 'signInV2', [ExtUserAuthMiddleware::class]); +Router::add('GET', '/api/v2/auth/user', UserController::class, 'getUserInfo', [ExtUserAuthMiddleware::class]); + + // Catalog controllers Router::add('GET', '/catalog', CatalogController::class, 'index', []); Router::add('GET', '/catalog/create', CatalogController::class, 'create', [AdminAuthMiddleware::class]); Router::add('GET', '/catalog/([A-Za-z0-9\-]*)', CatalogController::class, 'detail', []); Router::add('GET', '/catalog/([A-Za-z0-9\-]*)/edit', CatalogController::class, 'edit', [AdminAuthMiddleware::class]); -Router::add('POST', '/api/catalog/create', CatalogController::class, 'postCreate', [AdminAuthMiddleware::class]); + +Router::add('POST', '/api/catalog', CatalogController::class, 'postCreate', [AdminAuthMiddleware::class]); Router::add('GET', '/api/catalog', CatalogController::class, "search", [UserAuthApiMiddleware::class]); -Router::add("DELETE", "/api/catalog/([A-Za-z0-9\-]*)/delete", CatalogController::class, "delete", [AdminAuthMiddleware::class]); -Router::add("POST", '/api/catalog/([A-Za-z0-9\-]*)/update', CatalogController::class, 'update', [AdminAuthMiddleware::class]); +Router::add("DELETE", "/api/catalog/([A-Za-z0-9\-]*)", CatalogController::class, "delete", [AdminAuthMiddleware::class]); +Router::add("POST", '/api/catalog/([A-Za-z0-9\-]*)', CatalogController::class, 'update', [AdminAuthMiddleware::class]); + +Router::add("POST", "/api/v2/catalog-from-request", CatalogController::class, "createCatalogFromRequest", [ExtUserAuthMiddleware::class]); +Router::add("GET", "/api/v2/catalogs", CatalogController::class, "getCatalogs", [ExtUserAuthMiddleware::class]); +Router::add("GET", "/api/v2/catalog/([A-Za-z0-9\-]*)", CatalogController::class, "getCatalogByUUID", [ExtUserAuthMiddleware::class]); + +// Catalog request +Router::add('GET', '/catalog-request', CatalogRequestController::class, 'request', [UserAuthMiddleware::class]); +Router::add("POST", "/api/v2/catalog-request", CatalogRequestController::class, "create", [UserAuthMiddleware::class]); +Router::add("DELETE", "/api/v2/catalog-request/poster/([A-Za-z0-9\-\.]*)", CatalogRequestController::class, "deletePoster", [ExtUserAuthMiddleware::class]); +Router::add("DELETE", "/api/v2/catalog-request/trailer/([A-Za-z0-9\-\.]*)", CatalogRequestController::class, "deleteTrailer", [ExtUserAuthMiddleware::class]); // Watchlist controllers Router::add("GET", "/watchlist/create", WatchlistController::class, 'create', [UserAuthMiddleware::class]); @@ -50,7 +69,7 @@ Router::add("GET", "/api/watchlist/item", WatchlistController::class, 'item', [U Router::add("POST", "/api/watchlist/like", WatchlistController::class, "like", [UserAuthApiMiddleware::class]); Router::add("POST", "/api/watchlist/save", WatchlistController::class, "bookmark", [UserAuthApiMiddleware::class]); - +// Profile Router::add('GET', '/profile', UserController::class, 'showEditProfile', [UserAuthMiddleware::class]); Router::add('GET', '/profile/bookmark', BookmarkController::class, 'self', [UserAuthMiddleware::class]); Router::add('GET', '/profile/watchlist', WatchlistController::class, 'self', [UserAuthMiddleware::class]);