diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dce270a --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# FoOlSlideX .gitignore + +# Configuration files +config.php +session.php +.installed + +# Secrets +library/secrets/ + +# Database files +database/*/_cnt.sdb +database/*/cache/ +database/*/data/ + +# IDE and OS files +.DS_Store +Thumbs.db +.vscode/ +*.swp +*.swo + +# Logs +*.log \ No newline at end of file diff --git a/.installed b/.installed new file mode 100644 index 0000000..62b7dbf --- /dev/null +++ b/.installed @@ -0,0 +1 @@ +2025-08-13 18:01:32 \ No newline at end of file diff --git a/app/Controllers/Api/ApiController.php b/app/Controllers/Api/ApiController.php new file mode 100644 index 0000000..356b3f7 --- /dev/null +++ b/app/Controllers/Api/ApiController.php @@ -0,0 +1,101 @@ +jsonResponse([ + 'name' => 'FoOlSlideX API', + 'version' => '1.0', + 'endpoints' => [ + 'GET /api/titles' => 'Get all titles', + 'GET /api/titles/{id}' => 'Get a specific title', + 'GET /api/titles/{id}/chapters' => 'Get chapters for a title', + 'GET /api/chapters/{id}' => 'Get a specific chapter', + 'GET /api/search' => 'Search titles' + ] + ]); + } + + public function titles() + { + $page = $_GET['page'] ?? 1; + $limit = $_GET['limit'] ?? 20; + $offset = ($page - 1) * $limit; + + $titles = Title::query() + ->orderBy(['title' => 'ASC']) + ->limit($limit) + ->skip($offset) + ->getQuery() + ->fetch(); + + $this->jsonResponse([ + 'data' => $titles, + 'page' => (int)$page, + 'limit' => (int)$limit, + 'total' => Title::count() + ]); + } + + public function title($id) + { + $title = Title::find($id); + + if (!$title) { + $this->jsonResponse(['error' => 'Title not found'], 404); + return; + } + + $this->jsonResponse($title); + } + + public function titleChapters($id) + { + $chapters = Chapter::findByTitle($id); + + if (!$chapters) { + $this->jsonResponse(['error' => 'No chapters found for this title'], 404); + return; + } + + $this->jsonResponse($chapters); + } + + public function chapter($id) + { + $chapter = Chapter::find($id); + + if (!$chapter) { + $this->jsonResponse(['error' => 'Chapter not found'], 404); + return; + } + + $this->jsonResponse($chapter); + } + + public function search() + { + $query = $_GET['q'] ?? ''; + + if (empty($query)) { + $this->jsonResponse(['error' => 'Search query is required'], 400); + return; + } + + $titles = Title::searchByTitle($query); + + $this->jsonResponse([ + 'query' => $query, + 'results' => $titles, + 'count' => count($titles) + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/ApiController.php b/app/Controllers/ApiController.php new file mode 100644 index 0000000..6c216e6 --- /dev/null +++ b/app/Controllers/ApiController.php @@ -0,0 +1,17 @@ +redirect('api.php'); + } +} \ No newline at end of file diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..1f11948 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,112 @@ +redirect('index.php'); + return; + } + + $this->render("pages/login.tpl", [ + "pagetitle" => "Login " . $config["divider"] . " " . $config["title"] + ]); + } + + public function login() + { + global $db, $config, $logged; + + if ($logged) { + $this->redirect('index.php'); + return; + } + + // This would be implemented to handle the actual login logic + // For now, we'll just redirect to the original login page + $this->redirect('ajax/account/login.php'); + } + + public function showSignup() + { + global $config, $logged; + + if ($logged) { + $this->redirect('index.php'); + return; + } + + $this->render("pages/signup.tpl", [ + "pagetitle" => "Sign Up " . $config["divider"] . " " . $config["title"] + ]); + } + + public function signup() + { + global $db, $config, $logged; + + if ($logged) { + $this->redirect('index.php'); + return; + } + + // This would be implemented to handle the actual signup logic + // For now, we'll just redirect to the original signup page + $this->redirect('ajax/account/signup.php'); + } + + public function logout() + { + global $config; + + // This would be implemented to handle the actual logout logic + // For now, we'll just redirect to the original logout page + $this->redirect('ajax/account/logout.php'); + } + + public function activate() + { + global $config, $logged, $user; + + if (!$logged) { + $this->redirect('index.php'); + return; + } + + if ($logged && $user["level"] >= 50) { + $this->redirect('index.php'); + return; + } + + if (!isset($_GET["token"]) || empty($_GET["token"])) { + $this->redirect('index.php'); + return; + } + + $token = clean($_GET["token"] ?? ""); + + if (empty($token)) { + $this->redirect('index.php'); + return; + } + + $check = $db["activation"]->findOneBy(["token", "==", $token]); + if (empty($check)) { + $this->redirect('index.php'); + return; + } + + $data = array( + "level" => 50 + ); + $db["users"]->updateById($user["id"], $data); + $this->redirect('index.php'); + } +} \ No newline at end of file diff --git a/app/Controllers/ChapterController.php b/app/Controllers/ChapterController.php new file mode 100644 index 0000000..a7b8587 --- /dev/null +++ b/app/Controllers/ChapterController.php @@ -0,0 +1,100 @@ +redirect('index.php'); + return; + } + + $id = cat($id); + $chapter = $db["chapters"]->findById($id); + $title = $db["titles"]->findById($chapter["title"]); + + if (empty($title)) { + $this->redirect('titles.php'); + return; + } + + if (empty($chapter)) { + $this->redirect('title.php?id=' . $title["id"]); + return; + } + + $fct = formatChapterTitle($chapter["volume"], $chapter["number"], "full"); + + $chapters = $db["chapters"]->findBy(["title", "==", $title["id"]]); + + $isNextChapter = false; + $isPrevChapter = false; + + foreach ($chapters as $key => $ch) { + if ($ch["id"] == $id) { + if (!empty($chapters[($key + 1)])) $isNextChapter = $chapters[($key + 1)]["id"]; + if (!empty($chapters[($key - 1)])) $isPrevChapter = $chapters[($key - 1)]["id"]; + } + } + + $images = glob(ps(__DIR__ . "/../data/chapters/{$id}/*.{jpg,png,jpeg,webp,gif}"), GLOB_BRACE); + natsort($images); + + $comments = $db["chapterComments"]->findBy(["chapter.id", "==", $title["id"]], ["id" => "DESC"]); + + chapterVisit($chapter); + + $imgind = []; + $ic = 1; + foreach ($images as $ii) { + $ii = pathinfo($ii); + $imgind[$ic]["order"] = $ic; + $imgind[$ic]["name"] = $ii["filename"]; + $imgind[$ic]["ext"] = $ii["extension"]; + $ic++; + } + + $page = 1; + if ($readingmode == "single") { + if (!isset($_GET["page"]) || empty($_GET["page"]) || $_GET["page"] == "0") { + $this->redirect('chapter.php?id=' . $id . '&page=1'); + return; + } + + $page = cat($_GET["page"]); + + $isNextPage = true; + $isPrevPage = true; + + $nextPage = $page + 1; + $prevPage = $page - 1; + + $imgCount = count($images); + + if ($nextPage >= $imgCount) $isNextPage = false; + if ($prevPage <= 0) $isPrevPage = false; + } + + $this->render("pages/chapter.tpl", [ + "fullChapterTitle" => $fct, + "chapters" => $chapters, + "nextChapter" => $isNextChapter, + "prevChapter" => $isPrevChapter, + "images" => $images, + "commentsCount" => count($comments), + "title" => $title, + "chapter" => $chapter, + "imgind" => $imgind, + "pagetitle" => "Read " . $title["title"] . " " . formatChapterTitle($chapter["volume"], $chapter["number"]) . " " . $config["divider"] . " " . $config["title"], + "isNextPage" => $isNextPage ?? null, + "isPrevPage" => $isPrevPage ?? null, + "currentPage" => $page ?? null + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/ErrorController.php b/app/Controllers/ErrorController.php new file mode 100644 index 0000000..5136a9d --- /dev/null +++ b/app/Controllers/ErrorController.php @@ -0,0 +1,30 @@ +render("pages/404.tpl", [ + "pagetitle" => "Page Not Found " . $config["divider"] . " " . $config["title"] + ]); + } + + public function forbidden() + { + global $config; + + http_response_code(403); + + $this->render("pages/403.tpl", [ + "pagetitle" => "Access Forbidden " . $config["divider"] . " " . $config["title"] + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php new file mode 100644 index 0000000..923ea4a --- /dev/null +++ b/app/Controllers/HomeController.php @@ -0,0 +1,46 @@ +createQueryBuilder() + ->orderBy(["id" => "DESC"]) + ->distinct(["title.id"]) + ->getQuery() + ->fetch(); + + foreach ($recentlyUpdated as $key => $rec) { + $title = $db["titles"]->findById($rec["title"]); + $recentlyUpdated[$key]["title"] = $title; + $recentlyUpdated[$key]["title"]["summary1"] = shorten($parsedown->text($purifier->purify($title["summary"])), 400); + $recentlyUpdated[$key]["title"]["summary2"] = shorten($parsedown->text($purifier->purify($title["summary"])), 100); + } + + $chapters = $db["chapters"]->createQueryBuilder() + ->orderBy(["id" => "DESC"]) + ->limit($config["perpage"]["chapters"]) + ->getQuery() + ->fetch(); + + foreach ($chapters as $key => $ch) { + $title = $db["titles"]->findById($ch["title"]); + $uploader = $db["users"]->findById($ch["user"]); + $chapters[$key]["title"] = $title; + $chapters[$key]["user"] = $uploader; + } + + $this->render("pages/index.tpl", [ + "chapters" => $chapters, + "recentlyUpdated" => $recentlyUpdated, + "pagetitle" => $config["title"] . " " . $config["divider"] . " " . $config["slogan"] + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/ReleasesController.php b/app/Controllers/ReleasesController.php new file mode 100644 index 0000000..535ef61 --- /dev/null +++ b/app/Controllers/ReleasesController.php @@ -0,0 +1,45 @@ +createQueryBuilder() + ->orderBy(["id" => "DESC"]) + ->limit($limit) + ->skip($skip) + ->getQuery() + ->fetch(); + + foreach ($chapters as $key => $ch) { + $title = $db["titles"]->findById($ch["title"]); + $uploader = $db["users"]->findById($ch["user"]); + $chapters[$key]["title"] = $title; + $chapters[$key]["user"] = $uploader; + } + + $pagis = array(); + $totalPages = ceil($db["chapters"]->count() / $limit); + for ($i = 1; $i <= $totalPages; $i++) { + array_push($pagis, $i); + } + + $this->render("pages/releases.tpl", [ + "page" => $page, + "chapters" => $chapters, + "pagis" => $pagis, + "totalPages" => $totalPages, + "pagetitle" => "Releases - Page " . $page . " " . $config["divider"] . " " . $config["title"] + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/TitleController.php b/app/Controllers/TitleController.php new file mode 100644 index 0000000..1becb32 --- /dev/null +++ b/app/Controllers/TitleController.php @@ -0,0 +1,156 @@ +redirect('titles.php'); + return; + } + + $id = cat($id); + $title = $db["titles"]->findById($id); + + if (empty($title)) { + $this->redirect('titles.php'); + return; + } + + $title["authors2"] = $title["authors"]; + $title["artists2"] = $title["artists"]; + if (!empty($title["authors"])) $title["authors"] = explode(",", $title["authors"]); + if (!empty($title["artists"])) $title["artists"] = explode(",", $title["artists"]); + + if (!empty($title["tags"]["formats"])) { + $_array = array(); + foreach ($title["tags"]["formats"] as $item) { + if (!empty($item) && is_numeric($item)) { + $call = getTag("format", $item); + if (!empty($call)) array_push($_array, $call); + } + } + $title["tags"]["_formats"] = $title["tags"]["formats"]; + $title["tags"]["formats"] = $_array; + } else { + $title["tags"]["_formats"] = array(); + } + if (!empty($title["tags"]["warnings"])) { + $_array = array(); + foreach ($title["tags"]["warnings"] as $item) { + if (!empty($item) && is_numeric($item)) { + $call = getTag("warnings", $item); + if (!empty($call)) array_push($_array, $call); + } + } + $title["tags"]["_warnings"] = $title["tags"]["warnings"]; + $title["tags"]["warnings"] = $_array; + } else { + $title["tags"]["_warnings"] = array(); + } + if (!empty($title["tags"]["themes"])) { + $_array = array(); + foreach ($title["tags"]["themes"] as $item) { + if (!empty($item) && is_numeric($item)) { + $call = getTag("theme", $item); + if (!empty($call)) array_push($_array, $call); + } + } + $title["tags"]["_themes"] = $title["tags"]["themes"]; + $title["tags"]["themes"] = $_array; + } else { + $title["tags"]["_themes"] = array(); + } + if (!empty($title["tags"]["genres"])) { + $_array = array(); + foreach ($title["tags"]["genres"] as $item) { + if (!empty($item) && is_numeric($item)) { + $call = getTag("genre", $item); + if (!empty($call)) array_push($_array, $call); + } + } + $title["tags"]["_genres"] = $title["tags"]["genres"]; + $title["tags"]["genres"] = $_array; + } else { + $title["tags"]["_genres"] = array(); + } + + $upl = []; + if ($logged && $user["level"] >= 75) { + // Gotta implement a function here that makes it for all files inside /custom/ + $upl["theme"] = valCustom("theme"); + $upl["genre"] = valCustom("genre"); + $upl["warnings"] = valCustom("warnings"); + $upl["format"] = valCustom("format"); + $upl["langs"] = valCLang("upload_langs"); + } + + if (!empty($title["summary"])) { + $title["summary2"] = $title["summary"]; + $title["summary"] = $parsedown->text($purifier->purify($title["summary"])); + } else { + $title["summary2"] = ""; + } + + $chapters = array(); + $chapterLangs = array(); + $chapterCount = 0; + if (!empty($db["chapters"]->findOneBy(["title", "==", $title["id"]]))) { + // Chapter Languages + $chapterLangs = $db["chapters"]->createQueryBuilder() + ->select(["language"]) + ->where(["title", "==", $title["id"]]) + ->distinct("language.0") + ->getQuery() + ->fetch(); + sort($chapterLangs); + // Preferred Language + if (!in_array($preflang, array_column(array_column($chapterLangs, "language"), 1))) { + // Set preferred language if not available + } + // Actual Chapters + foreach ($chapterLangs as $key => $chLang) { + $_chapters = $db["chapters"]->createQueryBuilder() + ->where([["title", "==", $title["id"]], "AND", ["language.1", "==", $chLang["language"][1]]]) + ->orderBy(["volume" => "DESC"]) + ->orderBy(["number" => "DESC"]) + ->getQuery() + ->fetch(); + if (!empty($_chapters)) { + $chapterLangs[$key]["language"]["chapters"] = array(); + $chapterLangs[$key]["language"]["count"] = count($_chapters); + foreach ($_chapters as $_chapter) { + $uploader = $db["users"]->findById($_chapter["user"]); + $_chapter["user"] = $uploader; + // array_push($chapterLangs[$chLang[]], $_chapter); + array_push($chapterLangs[$key]["language"]["chapters"], $_chapter); + $chapterCount++; + } + } + } + } + + $comments = $db["titleComments"]->findBy(["title", "==", $title["id"]], ["id" => "DESC"]); + + titleVisit($title); + + $this->render("pages/title.tpl", [ + "commentsCount" => count($comments), + "chapterLangs" => $chapterLangs, + "chapterCount" => $chapterCount, + "pagetitle" => $title["title"] . " (Title) " . $config["divider"] . " " . $config["title"], + "title" => $title, + "upl_theme" => $upl["theme"] ?? null, + "upl_genre" => $upl["genre"] ?? null, + "upl_warnings" => $upl["warnings"] ?? null, + "upl_format" => $upl["format"] ?? null, + "upl_langs" => $upl["langs"] ?? null + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/TitlesController.php b/app/Controllers/TitlesController.php new file mode 100644 index 0000000..4f3f1d7 --- /dev/null +++ b/app/Controllers/TitlesController.php @@ -0,0 +1,51 @@ +createQueryBuilder() + ->orderBy(["title" => "ASC"]) + ->limit($limit) + ->skip($skip) + ->getQuery() + ->fetch(); + + $pagis = array(); + $totalPages = ceil($db["titles"]->count() / $limit); + for ($i = 1; $i <= $totalPages; $i++) { + array_push($pagis, $i); + } + + $upl = []; + if($logged && $user["level"] >= 75) { + // Gotta implement a function here that makes it for all files inside /custom/ + $upl["theme"] = valCustom("theme"); + $upl["genre"] = valCustom("genre"); + $upl["warnings"] = valCustom("warnings"); + $upl["format"] = valCustom("format"); + } + + $this->render("pages/titles.tpl", [ + "page" => $page, + "titles" => $titles, + "pagis" => $pagis, + "totalPages" => $totalPages, + "pagetitle" => "Titles - Page " . $page . " " . $config["divider"] . " " . $config["title"], + "upl_theme" => $upl["theme"] ?? null, + "upl_genre" => $upl["genre"] ?? null, + "upl_warnings" => $upl["warnings"] ?? null, + "upl_format" => $upl["format"] ?? null + ]); + } +} \ No newline at end of file diff --git a/app/Core/Cache.php b/app/Core/Cache.php new file mode 100644 index 0000000..fc41ad5 --- /dev/null +++ b/app/Core/Cache.php @@ -0,0 +1,83 @@ + $data['expires']) { + unlink($file); + return null; + } + + return $data['value']; + } + + public static function put($key, $value, $ttl = null) + { + $ttl = $ttl ?? self::$defaultTtl; + + // Create cache directory if it doesn't exist + if (!is_dir(self::$cacheDir)) { + mkdir(self::$cacheDir, 0755, true); + } + + $file = self::getCacheFile($key); + $data = [ + 'value' => $value, + 'expires' => time() + $ttl + ]; + + file_put_contents($file, serialize($data)); + } + + public static function forget($key) + { + $file = self::getCacheFile($key); + if (file_exists($file)) { + unlink($file); + } + } + + public static function flush() + { + $files = glob(self::$cacheDir . '*'); + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + + protected static function getCacheFile($key) + { + // Sanitize key for filesystem + $key = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $key); + return self::$cacheDir . $key . '.cache'; + } + + public static function remember($key, $ttl, $callback) + { + $value = self::get($key); + if ($value !== null) { + return $value; + } + + $value = call_user_func($callback); + self::put($key, $value, $ttl); + return $value; + } +} \ No newline at end of file diff --git a/app/Core/Controller.php b/app/Core/Controller.php new file mode 100644 index 0000000..d6244fb --- /dev/null +++ b/app/Core/Controller.php @@ -0,0 +1,71 @@ +smarty = $smarty; + $this->smarty->assign("config", $config); + $this->smarty->assign("lang", $lang); + $this->smarty->assign("theme", $theme); + $this->smarty->assign("userlang", $userlang); + $this->smarty->assign("usertheme", $usertheme); + $this->smarty->assign("version", $version); + $this->smarty->assign("logged", $logged); + $this->smarty->assign("user", $user); + $this->smarty->assign("preflang", $preflang); + } + + protected function render($template, $data = []) + { + foreach ($data as $key => $value) { + $this->smarty->assign($key, $value); + } + + // Use output buffering to capture all content + ob_start(); + + try { + // Render header + $this->smarty->display("parts/header.tpl"); + + // Render main template + $this->smarty->display($template); + + // Render footer + $this->smarty->display("parts/footer.tpl"); + + // Get the complete content + $content = ob_get_contents(); + ob_end_clean(); + + // Send content-length header and output all at once + if (!headers_sent()) { + header('Content-Length: ' . strlen($content)); + } + echo $content; + + } catch (Exception $e) { + ob_end_clean(); + throw $e; + } + } + + protected function redirect($url) + { + Response::redirect($url); + } + + protected function jsonResponse($data, $code = 200) + { + Response::json($data, $code); + } +} \ No newline at end of file diff --git a/app/Core/Csrf.php b/app/Core/Csrf.php new file mode 100644 index 0000000..7a25449 --- /dev/null +++ b/app/Core/Csrf.php @@ -0,0 +1,60 @@ + $hourAgo; + }); + + return $token; + } + + public static function verify($token) + { + if (!isset($_SESSION)) { + session_start(); + } + + if (!isset($_SESSION['csrf_tokens']) || !is_array($_SESSION['csrf_tokens'])) { + return false; + } + + if (!isset($_SESSION['csrf_tokens'][$token])) { + return false; + } + + // Check if token is expired (older than 1 hour) + $tokenTime = $_SESSION['csrf_tokens'][$token]; + if (time() - $tokenTime > 3600) { + unset($_SESSION['csrf_tokens'][$token]); + return false; + } + + // Remove token after use + unset($_SESSION['csrf_tokens'][$token]); + return true; + } + + public static function field() + { + $token = self::generate(); + return ''; + } +} \ No newline at end of file diff --git a/app/Core/FileUpload.php b/app/Core/FileUpload.php new file mode 100644 index 0000000..3a50410 --- /dev/null +++ b/app/Core/FileUpload.php @@ -0,0 +1,137 @@ +file = $_FILES[$fileKey]; + } + } + + public function name($name) + { + $this->name = $name; + return $this; + } + + public function allowedTypes($types) + { + $this->allowedTypes = $types; + return $this; + } + + public function maxSize($size) + { + $this->maxSize = $size; + return $this; + } + + public function uploadDir($dir) + { + $this->uploadDir = rtrim($dir, '/') . '/'; + return $this; + } + + public function validate() + { + if (!$this->file) { + throw new \Exception('No file provided'); + } + + // Check for upload errors + if ($this->file['error'] !== UPLOAD_ERR_OK) { + throw new \Exception('File upload error: ' . $this->file['error']); + } + + // Check file size + if ($this->maxSize > 0 && $this->file['size'] > $this->maxSize) { + throw new \Exception('File size exceeds maximum allowed size'); + } + + // Check file type + if (!empty($this->allowedTypes)) { + $fileType = mime_content_type($this->file['tmp_name']); + if (!in_array($fileType, $this->allowedTypes) && + !in_array($this->file['type'], $this->allowedTypes)) { + throw new \Exception('File type not allowed'); + } + } + + return $this; + } + + public function save() + { + $this->validate(); + + // Generate filename if not provided + if (!$this->name) { + $extension = pathinfo($this->file['name'], PATHINFO_EXTENSION); + $this->name = uniqid() . '.' . $extension; + } + + // Create upload directory if it doesn't exist + if (!is_dir($this->uploadDir)) { + mkdir($this->uploadDir, 0755, true); + } + + // Move uploaded file + $destination = $this->uploadDir . $this->name; + if (!move_uploaded_file($this->file['tmp_name'], $destination)) { + throw new \Exception('Failed to move uploaded file'); + } + + return $this->name; + } + + public function saveAsTemp() + { + $this->validate(); + + // Generate temporary filename + $extension = pathinfo($this->file['name'], PATHINFO_EXTENSION); + $tempName = uniqid() . '.' . $extension; + $tempDir = '../public/data/tmp/'; + + // Create temp directory if it doesn't exist + if (!is_dir($tempDir)) { + mkdir($tempDir, 0755, true); + } + + $destination = $tempDir . $tempName; + if (!move_uploaded_file($this->file['tmp_name'], $destination)) { + throw new \Exception('Failed to move uploaded file to temp directory'); + } + + return $tempName; + } + + public static function delete($path) + { + if (file_exists($path)) { + unlink($path); + } + } + + public static function sanitizeFileName($fileName) + { + // Remove illegal characters + $fileName = preg_replace('/[^a-zA-Z0-9._-]/', '', $fileName); + + // Limit length + if (strlen($fileName) > 255) { + $fileName = substr($fileName, 0, 255); + } + + return $fileName; + } +} \ No newline at end of file diff --git a/app/Core/Helper.php b/app/Core/Helper.php new file mode 100644 index 0000000..06bb229 --- /dev/null +++ b/app/Core/Helper.php @@ -0,0 +1,123 @@ +diff($ago); + + $diff->w = floor($diff->d / 7); + $diff->d -= $diff->w * 7; + + $string = array( + 'y' => 'year', + 'm' => 'month', + 'w' => 'week', + 'd' => 'day', + 'h' => 'hour', + 'i' => 'min', + 's' => 'second', + ); + foreach ($string as $k => &$v) { + if ($diff->$k) { + $v = $diff->$k . ' ' . $v . ($diff->$k > 1 ? 's' : ''); + } else { + unset($string[$k]); + } + } + + if (!$full) $string = array_slice($string, 0, 1); + return $string ? implode(', ', $string) . ' ago' : 'just now'; + } + + public static function shorten($text, $maxlength = 25) + { + // This function is used, to display only a certain amount of characters + if (strlen($text) > $maxlength) + return substr($text, 0, $maxlength) . "..."; + return $text; + } + + public static function strContains($haystack, $needle) + { + // Only available in PHP 8.x which I don't like at all + return $needle !== "" && mb_strpos($haystack, $needle) !== false; + } +} \ No newline at end of file diff --git a/app/Core/Logger.php b/app/Core/Logger.php new file mode 100644 index 0000000..63bbef3 --- /dev/null +++ b/app/Core/Logger.php @@ -0,0 +1,81 @@ + 100, + 'INFO' => 200, + 'WARNING' => 300, + 'ERROR' => 400, + 'CRITICAL' => 500 + ]; + + public static function debug($message, $context = []) + { + self::log('DEBUG', $message, $context); + } + + public static function info($message, $context = []) + { + self::log('INFO', $message, $context); + } + + public static function warning($message, $context = []) + { + self::log('WARNING', $message, $context); + } + + public static function error($message, $context = []) + { + self::log('ERROR', $message, $context); + } + + public static function critical($message, $context = []) + { + self::log('CRITICAL', $message, $context); + } + + protected static function log($level, $message, $context = []) + { + // Create log directory if it doesn't exist + $logDir = dirname(self::$logFile); + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Format the log message + $timestamp = date('Y-m-d H:i:s'); + $contextString = !empty($context) ? json_encode($context) : ''; + $logMessage = "[{$timestamp}] {$level}: {$message} {$contextString}" . PHP_EOL; + + // Write to log file + file_put_contents(self::$logFile, $logMessage, FILE_APPEND | LOCK_EX); + } + + public static function getLogs($level = null, $limit = 100) + { + if (!file_exists(self::$logFile)) { + return []; + } + + $logs = file(self::$logFile, FILE_IGNORE_NEW_LINES); + $filteredLogs = []; + + if ($level) { + $level = strtoupper($level); + foreach ($logs as $log) { + if (strpos($log, "[{$level}]") !== false) { + $filteredLogs[] = $log; + } + } + } else { + $filteredLogs = $logs; + } + + // Return last N logs + return array_slice($filteredLogs, -$limit); + } +} \ No newline at end of file diff --git a/app/Core/Middleware.php b/app/Core/Middleware.php new file mode 100644 index 0000000..303eb2e --- /dev/null +++ b/app/Core/Middleware.php @@ -0,0 +1,56 @@ +findAll(); + } + + public static function find($id) + { + return self::$db[static::$table]->findById($id); + } + + public static function where($field, $operator, $value) + { + return self::$db[static::$table]->findBy([$field, $operator, $value]); + } + + public static function firstWhere($field, $operator, $value) + { + return self::$db[static::$table]->findOneBy([$field, $operator, $value]); + } + + public static function create($data) + { + return self::$db[static::$table]->insert($data); + } + + public static function update($id, $data) + { + return self::$db[static::$table]->updateById($id, $data); + } + + public static function delete($id) + { + return self::$db[static::$table]->deleteById($id); + } + + public static function query() + { + return self::$db[static::$table]->createQueryBuilder(); + } + + public static function count() + { + return self::$db[static::$table]->count(); + } +} \ No newline at end of file diff --git a/app/Core/Request.php b/app/Core/Request.php new file mode 100644 index 0000000..e183e68 --- /dev/null +++ b/app/Core/Request.php @@ -0,0 +1,67 @@ + [], + 'POST' => [], + 'HEAD' => [] + ]; + + public function get($uri, $controller, $middleware = []) + { + $this->routes['GET'][$uri] = [ + 'controller' => $controller, + 'middleware' => $middleware + ]; + } + + public function post($uri, $controller, $middleware = []) + { + $this->routes['POST'][$uri] = [ + 'controller' => $controller, + 'middleware' => $middleware + ]; + } + + public function resolve($uri, $method) + { + // Remove query string parameters + $uri = strtok($uri, '?'); + + // Remove leading slash + $uri = trim($uri, '/'); + + // If URI is empty, default to home + if (empty($uri)) { + $uri = '/'; + } + + // Handle HEAD requests as GET requests + $routeMethod = $method === 'HEAD' ? 'GET' : $method; + + // Check if method is supported + if (!isset($this->routes[$routeMethod])) { + return $this->callController([ + 'controller' => 'ErrorController@notFound', + 'middleware' => [] + ]); + } + + if (isset($this->routes[$routeMethod][$uri])) { + return $this->callController($this->routes[$routeMethod][$uri]); + } + + // Handle dynamic routes (e.g., /title/123) + foreach ($this->routes[$routeMethod] as $route => $routeData) { + $routePattern = $this->convertToRegex($route); + if (preg_match($routePattern, $uri, $matches)) { + // Remove the first match (full match) + array_shift($matches); + return $this->callController($routeData, $matches); + } + } + + // Route not found + return $this->callController([ + 'controller' => 'ErrorController@notFound', + 'middleware' => [] + ]); + } + + protected function convertToRegex($route) + { + // Convert route parameters like /title/{id} to regex pattern + $route = preg_replace('/\//', '\\/', $route); + $route = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([a-zA-Z0-9_]+)', $route); + return '/^' . $route . '$/'; + } + + protected function callController($routeData, $params = []) + { + // Run middleware + foreach ($routeData['middleware'] as $middleware) { + Middleware::run($middleware); + } + + // Split controller and method + $parts = explode('@', $routeData['controller']); + $controllerName = $parts[0]; + $method = $parts[1] ?? 'index'; + + // Build full class name + $class = "App\\Controllers\\{$controllerName}"; + + // Check if class exists + if (!class_exists($class)) { + throw new \Exception("Controller {$controllerName} not found"); + } + + // Create controller instance + $controllerInstance = new $class(); + + // Check if method exists + if (!method_exists($controllerInstance, $method)) { + throw new \Exception("Method {$method} not found in controller {$controllerName}"); + } + + // Call method with parameters + return call_user_func_array([$controllerInstance, $method], $params); + } +} \ No newline at end of file diff --git a/app/Core/Validator.php b/app/Core/Validator.php new file mode 100644 index 0000000..be34674 --- /dev/null +++ b/app/Core/Validator.php @@ -0,0 +1,98 @@ + $rule) { + $validator->check($field, $data[$field] ?? null, $rule); + } + return $validator; + } + + protected function check($field, $value, $rules) + { + $rules = explode('|', $rules); + foreach ($rules as $rule) { + if (!$this->passes($value, $rule)) { + $this->errors[$field][] = $this->message($field, $rule); + } + } + } + + protected function passes($value, $rule) + { + switch ($rule) { + case 'required': + return !empty($value); + case 'email': + return filter_var($value, FILTER_VALIDATE_EMAIL); + case 'numeric': + return is_numeric($value); + case 'array': + return is_array($value); + default: + if (strpos($rule, 'min:') === 0) { + $min = substr($rule, 4); + return strlen($value) >= $min; + } + if (strpos($rule, 'max:') === 0) { + $max = substr($rule, 4); + return strlen($value) <= $max; + } + if (strpos($rule, 'size:') === 0) { + $size = substr($rule, 5); + return strlen($value) == $size; + } + return true; + } + } + + protected function message($field, $rule) + { + switch ($rule) { + case 'required': + return "The {$field} field is required."; + case 'email': + return "The {$field} must be a valid email address."; + case 'numeric': + return "The {$field} must be a number."; + case 'array': + return "The {$field} must be an array."; + default: + if (strpos($rule, 'min:') === 0) { + $min = substr($rule, 4); + return "The {$field} must be at least {$min} characters."; + } + if (strpos($rule, 'max:') === 0) { + $max = substr($rule, 4); + return "The {$field} may not be greater than {$max} characters."; + } + if (strpos($rule, 'size:') === 0) { + $size = substr($rule, 5); + return "The {$field} must be {$size} characters."; + } + return "The {$field} field is invalid."; + } + } + + public function fails() + { + return !empty($this->errors); + } + + public function errors() + { + return $this->errors; + } + + public function first($field) + { + return $this->errors[$field][0] ?? null; + } +} \ No newline at end of file diff --git a/app/Models/Bookmark.php b/app/Models/Bookmark.php new file mode 100644 index 0000000..edb72c4 --- /dev/null +++ b/app/Models/Bookmark.php @@ -0,0 +1,50 @@ + $userId, + 'title' => $titleId, + 'timestamp' => date('Y-m-d H:i:s') + ]; + + return self::create($data); + } + + public static function removeBookmark($userId, $titleId) + { + $bookmark = self::findByUserAndTitle($userId, $titleId); + if ($bookmark) { + return self::delete($bookmark['id']); + } + return false; + } + + public static function isBookmarked($userId, $titleId) + { + return self::findByUserAndTitle($userId, $titleId) !== null; + } +} \ No newline at end of file diff --git a/app/Models/Chapter.php b/app/Models/Chapter.php new file mode 100644 index 0000000..380cf0a --- /dev/null +++ b/app/Models/Chapter.php @@ -0,0 +1,34 @@ +orderBy(['id' => 'DESC']) + ->limit($limit) + ->getQuery() + ->fetch(); + } + + public static function findByLanguage($titleId, $language) + { + return self::query() + ->where([['title', '=', $titleId], 'AND', ['language.1', '=', $language]]) + ->orderBy(['volume' => 'DESC']) + ->orderBy(['number' => 'DESC']) + ->getQuery() + ->fetch(); + } +} \ No newline at end of file diff --git a/app/Models/ReadingHistory.php b/app/Models/ReadingHistory.php new file mode 100644 index 0000000..2ec7b18 --- /dev/null +++ b/app/Models/ReadingHistory.php @@ -0,0 +1,74 @@ + $page, + 'last_read' => date('Y-m-d H:i:s') + ]); + } + + // Create new record + $data = [ + 'user' => $userId, + 'chapter' => $chapterId, + 'page' => $page, + 'last_read' => date('Y-m-d H:i:s') + ]; + + return self::create($data); + } + + public static function getRecentHistory($userId, $limit = 10) + { + return self::query() + ->where(['user', '=', $userId]) + ->orderBy(['last_read' => 'DESC']) + ->limit($limit) + ->getQuery() + ->fetch(); + } + + public static function getProgress($userId, $titleId) + { + // Get all chapters for this title + $chapters = \App\Models\Chapter::findByTitle($titleId); + + // Get reading history for this user and title's chapters + $history = []; + foreach ($chapters as $chapter) { + $record = self::findByUserAndChapter($userId, $chapter['id']); + if ($record) { + $history[] = $record; + } + } + + return [ + 'total_chapters' => count($chapters), + 'read_chapters' => count($history), + 'progress' => count($chapters) > 0 ? (count($history) / count($chapters)) * 100 : 0, + 'history' => $history + ]; + } +} \ No newline at end of file diff --git a/app/Models/Session.php b/app/Models/Session.php new file mode 100644 index 0000000..d637fb8 --- /dev/null +++ b/app/Models/Session.php @@ -0,0 +1,24 @@ + $level]); + } +} \ No newline at end of file diff --git a/app/autoload.php b/app/autoload.php new file mode 100644 index 0000000..a8e0db3 --- /dev/null +++ b/app/autoload.php @@ -0,0 +1,25 @@ +get('/', 'HomeController@index'); + +// Titles pages +$router->get('/titles', 'TitlesController@index'); +$router->get('/titles/{page}', 'TitlesController@index'); +$router->get('/title/{id}', 'TitleController@show'); + +// Chapters pages +$router->get('/chapter/{id}', 'ChapterController@show'); +$router->get('/releases', 'ReleasesController@index'); +$router->get('/releases/{page}', 'ReleasesController@index'); + +// User authentication +$router->get('/login', 'AuthController@showLogin'); +$router->post('/login', 'AuthController@login', ['csrf']); +$router->get('/signup', 'AuthController@showSignup'); +$router->post('/signup', 'AuthController@signup', ['csrf']); +$router->get('/logout', 'AuthController@logout'); +$router->get('/activate', 'AuthController@activate'); + +// API routes +$router->get('/api', 'ApiController@index'); +$router->get('/api/titles', 'Api\\ApiController@titles'); +$router->get('/api/titles/{id}', 'Api\\ApiController@title'); +$router->get('/api/titles/{id}/chapters', 'Api\\ApiController@titleChapters'); +$router->get('/api/chapters/{id}', 'Api\\ApiController@chapter'); +$router->get('/api/search', 'Api\\ApiController@search'); + +// Error routes +$router->get('/error/404', 'ErrorController@notFound'); +$router->get('/error/403', 'ErrorController@forbidden'); + +return $router; \ No newline at end of file diff --git a/autoload.php b/autoload.php index 19451e6..6434053 100644 --- a/autoload.php +++ b/autoload.php @@ -10,6 +10,9 @@ $config["debug"] == true ? error_reporting(E_ALL) && ini_set('display_errors', 1) : error_reporting(0) && ini_set('display_errors', 0); require_once "funky.php"; +// App autoloader for MVC structure +require_once "app/autoload.php"; + // SleekDB require_once ps(__DIR__ . $config["path"]["sleek"] . "/Store.php"); $db["users"] = new \SleekDB\Store("users", ps(__DIR__ . $config["db"]["sleek"]["dir"]), $config["db"]["sleek"]["config"]); @@ -95,3 +98,6 @@ $smarty->assign("logged", $logged); $smarty->assign("user", $user); $smarty->assign("preflang", $preflang); + +// Initialize database for models +App\Core\Model::setDatabase($db); diff --git a/config.php b/config.php index 8288454..6288bfc 100644 --- a/config.php +++ b/config.php @@ -6,7 +6,7 @@ $config["logs"] = true; $config["debug"] = true; $config["url"] = "http://localhost/fsx/public/"; -$config["email"] = "saintly@h33t.moe"; +$config["email"] = "test@example.com"; $config["activation"] = false; // Activate account through email? $config["shareAnonymousAnalytics"] = true; $config["api"] = true; // not really working rn, still please let it enabled until newer versions @@ -43,7 +43,8 @@ // Themes and Languages $config["themes"] = array( - "nucleus" => "Nucleus" + "nucleus" => "Nucleus", + "netflix" => "Netflix" ); $config["langs"] = array( "en" => "English", diff --git a/create_admin.php b/create_admin.php new file mode 100644 index 0000000..e4f6262 --- /dev/null +++ b/create_admin.php @@ -0,0 +1,35 @@ +findAll(null, 1); +if (!empty($existingUsers)) { + die("Admin user already exists!"); +} + +// Create admin user data +$username = "admin"; +$password = password_hash("admin123", PASSWORD_BCRYPT); +$email = "admin@example.com"; + +$data = array( + "username" => $username, + "password" => $password, + "email" => $email, + "avatar" => $config["default"]["avatar"], + "level" => 100, // Administrator level + "theme" => $config["default"]["theme"], + "lang" => $config["default"]["lang"], + "banned" => false, + "bannedReason" => null, + "timestamp" => now() +); + +// Insert the user +$db["users"]->insert($data); + +echo "Admin user created successfully!\n"; +echo "Username: admin\n"; +echo "Password: admin123\n"; +echo "Email: admin@example.com\n"; \ No newline at end of file diff --git a/database/activation/_cnt.sdb b/database/activation/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/activation/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/alertReads/_cnt.sdb b/database/alertReads/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/alertReads/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/alerts/_cnt.sdb b/database/alerts/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/alerts/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/alerts/cache/d1426e828ca5e3290a674976a6ee043b.no_lifetime.json b/database/alerts/cache/d1426e828ca5e3290a674976a6ee043b.no_lifetime.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/database/alerts/cache/d1426e828ca5e3290a674976a6ee043b.no_lifetime.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/database/chapterComments/_cnt.sdb b/database/chapterComments/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/chapterComments/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/chapterViews/_cnt.sdb b/database/chapterViews/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/chapterViews/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/chapters/_cnt.sdb b/database/chapters/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/chapters/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/chapters/cache/ce4ef4420ef09f9e1a13c1a7ba46022f.no_lifetime.json b/database/chapters/cache/ce4ef4420ef09f9e1a13c1a7ba46022f.no_lifetime.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/database/chapters/cache/ce4ef4420ef09f9e1a13c1a7ba46022f.no_lifetime.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/database/chapters/cache/cef09747df32485d44d903f9d476bbab.no_lifetime.json b/database/chapters/cache/cef09747df32485d44d903f9d476bbab.no_lifetime.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/database/chapters/cache/cef09747df32485d44d903f9d476bbab.no_lifetime.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/database/profileComments/_cnt.sdb b/database/profileComments/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/profileComments/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/profileViews/_cnt.sdb b/database/profileViews/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/profileViews/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/readChapters/_cnt.sdb b/database/readChapters/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/readChapters/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/sessions/_cnt.sdb b/database/sessions/_cnt.sdb new file mode 100644 index 0000000..e440e5c --- /dev/null +++ b/database/sessions/_cnt.sdb @@ -0,0 +1 @@ +3 \ No newline at end of file diff --git a/database/titleComments/_cnt.sdb b/database/titleComments/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/titleComments/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/titleViews/_cnt.sdb b/database/titleViews/_cnt.sdb new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/database/titleViews/_cnt.sdb @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/database/titles/_cnt.sdb b/database/titles/_cnt.sdb new file mode 100644 index 0000000..56a6051 --- /dev/null +++ b/database/titles/_cnt.sdb @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/database/users/_cnt.sdb b/database/users/_cnt.sdb new file mode 100644 index 0000000..56a6051 --- /dev/null +++ b/database/users/_cnt.sdb @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/database/visitLogs/_cnt.sdb b/database/visitLogs/_cnt.sdb new file mode 100644 index 0000000..d8263ee --- /dev/null +++ b/database/visitLogs/_cnt.sdb @@ -0,0 +1 @@ +2 \ No newline at end of file diff --git a/database/visitLogs/cache/47878e419a85d26981bece57eab7242b.no_lifetime.json b/database/visitLogs/cache/47878e419a85d26981bece57eab7242b.no_lifetime.json new file mode 100644 index 0000000..82db654 --- /dev/null +++ b/database/visitLogs/cache/47878e419a85d26981bece57eab7242b.no_lifetime.json @@ -0,0 +1 @@ +{"ip":"::1","timestamp":"2025-08-13 18:02:09","id":1} \ No newline at end of file diff --git a/database/visitLogs/data/1.json b/database/visitLogs/data/1.json new file mode 100644 index 0000000..82db654 --- /dev/null +++ b/database/visitLogs/data/1.json @@ -0,0 +1 @@ +{"ip":"::1","timestamp":"2025-08-13 18:02:09","id":1} \ No newline at end of file diff --git a/database/visitLogs/data/2.json b/database/visitLogs/data/2.json new file mode 100644 index 0000000..5183c7f --- /dev/null +++ b/database/visitLogs/data/2.json @@ -0,0 +1 @@ +{"ip":"","timestamp":"2025-08-13 18:43:26","id":2} \ No newline at end of file diff --git a/install_app.php b/install_app.php new file mode 100644 index 0000000..cf52fbb --- /dev/null +++ b/install_app.php @@ -0,0 +1,49 @@ + "Netflix", + "author" => "Theme Developer", + "website" => "https://github.com/foolslidex/themes", + "plugins" => [ + "daisyTheme", + "logoImage", + "readChapters", + "simpleAlert", + "started", + "userLangs", + "readingMode" + ], + "version" => "1.0.0" +]; \ No newline at end of file diff --git a/library/themes/netflix/pages/403.tpl b/library/themes/netflix/pages/403.tpl new file mode 100644 index 0000000..5c9f63c --- /dev/null +++ b/library/themes/netflix/pages/403.tpl @@ -0,0 +1,16 @@ + +
Sorry, you don't have permission to access this page.
+Sorry, we couldn't find the page you're looking for.
+No pages available for this chapter.
+ {/if} +No page content available.
+ {/if} +{$manga.title} - Chapter {$chapter.number}
+Brief description of the featured manga series that captures the reader's interest.
+
+ Chapter {$item.number}
+
+ {$item.chapterCount} chapters
+
+ Updated {$item.lastUpdate}
+Discover the newest chapters added to our collection
+
+ Chapter {$chapter.number}
+
+ {$chapter.language.0}
+ {$chapter.timestamp|date_format:"M j, Y"}
+No releases found.
+
+ {$title.summary}
+{$title.authors}
+{$title.artists}
+{$chapter.date}
+No chapters available for this title.
+ {/if} +
+ Discover your next favorite series
+
+ {$title.authors}
+No titles found matching your criteria.
+{$item.name}
++ by + {if !empty($item.website)} + + {/if} + {$item.author} + {if !empty($item.website)} + + {/if} +
+Updated: {$item.updated}
+