diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6deb63f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data/database/bookmarkly.db +data/images/ +bookmark_* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..87f9671 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM tobi312/php:8.4-apache + +# Set DocumentRoot +ENV APACHE_DOCUMENT_ROOT /var/www/html + +# Copy application files +COPY . /var/www/html + +# Create data directories and set permissions +RUN mkdir -p /var/www/html/data/database /var/www/html/data/images/custom /var/www/html/data/images/backgrounds && \ + chmod -R 775 /var/www/html/data + +# Copy and set up entrypoint +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["entrypoint.sh"] + +# Expose port and start Apache +EXPOSE 80 +CMD ["apache2-foreground"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a0a4ef5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 R0GGER + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index abc2f24..d99f555 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,61 @@ -# bookmarkly2 +# Bookmarkly 2.0 + +A clean, fast, and highly customizable self-hosted dashboard for your bookmarks. + + + +## Key Features + +- **Multi-Page Organization**: Group your bookmarks into independent pages using **Tabs**. Each tab can have its own set of categories, bookmarks, and even its own custom theme. +- **Advanced Customization**: + - **Themes**: Choose between Light, Dark, and Transparent modes. + - **Custom Backgrounds**: Set a unique background for each tab. Upload your own, use a URL, or find one with the built-in **Pexels** integration. + - **Fine-Tune**: Adjust background brightness and saturation. Add your own **Custom CSS** and footer HTML for a personalized touch. +- **Flexible Bookmark Management**: + - Organize links into **Categories** within each tab. + - Drag-and-drop sorting for both bookmarks and categories. + - **Favorites**: Mark your most-used bookmarks for quick access. + - **Custom Icons**: Upload your own icons or use the built-in favicon fetcher. +- **Backup & Restore**: + - **Full & Partial Backups**: Create a full backup of the entire site or a partial backup of a single tab—including its icons, background, and settings. + - **Seamless Importing**: Restore a full backup or import a tab backup into any existing tab. +- **Multi-User Ready**: Supports multiple users with different roles and permissions for viewing and editing tabs. +- **Easy Deployment**: Comes ready to deploy as a Docker container. + +## System Maintenance + +The settings page includes a "System Maintenance" section for administrative tasks. These tools help ensure data integrity and smooth updates. + +- **Update Database Schema**: Safely applies any necessary database changes required by new application features. It's safe to run this multiple times. +- **Fix Orphaned Bookmarks**: This tool solves a specific data problem where bookmarks can become "orphaned" (not linked to any tab). It finds these bookmarks and automatically assigns them to your first (default) tab, making them visible and usable again. + +## Getting Started (Docker) + +1. Clone this repository. +2. Run `docker compose up -d --build`. +3. Access your new dashboard at `http://localhost:8080`. + +**Default Login:** +- **Username:** `admin` +- **Password:** `password` + +> !! It is highly recommended to change the default password after your first login !! + +## Getting Started (Web Server) + +You can also run Bookmarkly 2.0 on a traditional web server. + +**Requirements:** +- Web server (e.g., Apache2 or Nginx) +- PHP 8.0 or newer +- SQLite PHP extension + +**Steps:** +1. Clone this repository to your web server's document root. +2. Ensure the `data/` directory is writable by the web server user. This is where the database and images are stored. +3. Access the site from your browser. + +**Default Login:** +- **Username:** `admin` +- **Password:** `password` diff --git a/ajax_update_tab_order.php b/ajax_update_tab_order.php new file mode 100644 index 0000000..d210482 --- /dev/null +++ b/ajax_update_tab_order.php @@ -0,0 +1,41 @@ + false, 'message' => 'Unauthorized']); + exit; +} + +$data = json_decode(file_get_contents('php://input'), true); + +if (isset($data['tab_ids']) && is_array($data['tab_ids'])) { + $tab_ids = $data['tab_ids']; + + try { + $pdo->beginTransaction(); + + $stmt = $pdo->prepare("UPDATE tabs SET sort_order = ? WHERE id = ?"); + + foreach ($tab_ids as $index => $tab_id) { + // Sort order is 1-based + $sort_order = $index + 1; + $stmt->execute([$sort_order, (int)$tab_id]); + } + + $pdo->commit(); + + header('Content-Type: application/json'); + echo json_encode(['success' => true]); + } catch (Exception $e) { + $pdo->rollBack(); + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]); + } +} else { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Invalid input']); +} diff --git a/api/backup.php b/api/backup.php new file mode 100644 index 0000000..551979b --- /dev/null +++ b/api/backup.php @@ -0,0 +1,297 @@ +open($destination, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) { + $error_message = 'Failed to create zip archive. Check server permissions and logs.'; + return false; + } + + $source = realpath($source); + + if (is_dir($source)) { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($files as $file) { + $file = realpath($file); + $relativePath = str_replace($source . DIRECTORY_SEPARATOR, '', $file); + + if (is_dir($file)) { + $zip->addEmptyDir($relativePath); + } else if (is_file($file)) { + $zip->addFromString($relativePath, file_get_contents($file)); + } + } + } + + if (!$zip->close()) { + $error_message = "Failed to write and close the zip archive. Check for disk space and permissions."; + return false; + } + + return true; +} + +$projectRoot = realpath(__DIR__ . '/..'); + +// --- Main Logic --- +$tab_id = $_GET['tab_id'] ?? 'full'; +$error_message = ''; + +if ($tab_id === 'full') { + // --- Full Backup Logic --- + $dataDir = 'data'; + $backupFileName = 'bookmarkly_full_backup_' . date('Y-m-d_H-i-s') . '.zip'; + $backupFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $backupFileName; + + if (createZipArchive($dataDir, $backupFilePath, $error_message)) { + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $backupFileName . '"'); + header('Content-Length: ' . filesize($backupFilePath)); + readfile($backupFilePath); + unlink($backupFilePath); + exit; + } else { + $_SESSION['flash_message'] = ['text' => 'Failed to create full backup. Reason: ' . $error_message, 'type' => 'danger']; + header('Location: ../settings.php'); + exit; + } +} else { + // --- Tab-specific Backup Logic --- + $tab_id = filter_var($tab_id, FILTER_VALIDATE_INT); + if ($tab_id === false) { + $_SESSION['flash_message'] = ['text' => 'Invalid Tab ID.', 'type' => 'danger']; + header('Location: ../settings.php'); + exit; + } + + $temp_dir = sys_get_temp_dir() . '/bookmarkly_tab_backup_' . uniqid(); + if (!mkdir($temp_dir, 0777, true)) { + $_SESSION['flash_message'] = ['text' => 'Failed to create temporary directory for backup.', 'type' => 'danger']; + header('Location: ../settings.php'); + exit; + } + + try { + // --- START DEBUGGING --- + $log_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'bookmarkly_backup_debug.log'; + $log_file = fopen($log_path, 'w'); + if ($log_file) { + fwrite($log_file, "--- Starting Tab Backup Debug ---\n"); + fwrite($log_file, "Log file location: " . $log_path . "\n"); + fwrite($log_file, "Time: " . date('c') . "\n"); + fwrite($log_file, "Project Root: " . $projectRoot . "\n\n"); + } + // --- END DEBUGGING --- + + // 1. Fetch data for the specific tab from the main DB + $tab_stmt = $pdo->prepare("SELECT * FROM tabs WHERE id = ?"); + $tab_stmt->execute([$tab_id]); + $tab = $tab_stmt->fetch(PDO::FETCH_ASSOC); + + if (!$tab) { + throw new Exception("Tab with ID {$tab_id} not found."); + } + + $cat_stmt = $pdo->prepare("SELECT * FROM categories WHERE tab_id = ?"); + $cat_stmt->execute([$tab_id]); + $categories = $cat_stmt->fetchAll(PDO::FETCH_ASSOC); + + $book_stmt = $pdo->prepare("SELECT * FROM bookmarks WHERE tab_id = ?"); + $book_stmt->execute([$tab_id]); + $bookmarks = $book_stmt->fetchAll(PDO::FETCH_ASSOC); + + // 2. Create temporary structure and database + $temp_db_path = $temp_dir . '/database/bookmarkly.db'; + mkdir($temp_dir . '/database', 0777, true); + mkdir($temp_dir . '/images/custom', 0777, true); // CORRECTED: Create the 'custom' directory + mkdir($temp_dir . '/images/backgrounds', 0777, true); + + $backup_pdo = new PDO('sqlite:' . $temp_db_path); + $backup_pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Create schema in backup DB + $backup_pdo->exec("CREATE TABLE tabs (id INTEGER PRIMARY KEY, name TEXT, background_image_url TEXT, background_brightness INTEGER, background_saturation INTEGER, theme TEXT, theme_transparency INTEGER)"); + $backup_pdo->exec("CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT, sort_order INTEGER, tab_id INTEGER)"); + $backup_pdo->exec("CREATE TABLE bookmarks (id INTEGER PRIMARY KEY, category_id INTEGER, title TEXT, url TEXT, icon TEXT, is_favorite INTEGER, sort_order INTEGER, tab_id INTEGER)"); + + // 3. Copy files and insert data into backup DB + + // Handle Tab Background + $new_background_path = $tab['background_image_url']; + if (!empty($tab['background_image_url']) && strpos($tab['background_image_url'], 'data/images/backgrounds/') === 0) { + $source_path_relative = strtok($tab['background_image_url'], '?'); + $source_path_absolute = $projectRoot . DIRECTORY_SEPARATOR . $source_path_relative; + if (file_exists($source_path_absolute)) { + $bg_filename = basename($source_path_relative); + $new_background_path_in_db = 'images/backgrounds/' . $bg_filename; + $destination_path = $temp_dir . DIRECTORY_SEPARATOR . $new_background_path_in_db; + if (copy($source_path_absolute, $destination_path)) { + $new_background_path = $new_background_path_in_db; + } + } + } + + // Insert Tab data + $insert_tab_stmt = $backup_pdo->prepare("INSERT INTO tabs VALUES (?, ?, ?, ?, ?, ?, ?)"); + $insert_tab_stmt->execute([$tab['id'], $tab['name'], $new_background_path, $tab['background_brightness'], $tab['background_saturation'], $tab['theme'], $tab['theme_transparency']]); + + // Handle Categories and Bookmarks + $insert_cat_stmt = $backup_pdo->prepare("INSERT INTO categories VALUES (?, ?, ?, ?)"); + foreach ($categories as $category) { + $insert_cat_stmt->execute([$category['id'], $category['name'], $category['sort_order'], $category['tab_id']]); + } + + $insert_book_stmt = $backup_pdo->prepare("INSERT INTO bookmarks VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); + foreach ($bookmarks as $bookmark) { + $icon_path_for_db = $bookmark['icon']; // Start with the original path + + // Only process locally stored custom icons + if (!empty($bookmark['icon']) && strpos($bookmark['icon'], 'data/images/custom/') === 0) { + + // --- START DEBUGGING --- + if ($log_file) fwrite($log_file, "Processing bookmark ID: " . $bookmark['id'] . " Title: '" . $bookmark['title'] . "'\n"); + // --- END DEBUGGING --- + + $relative_source_path = strtok($bookmark['icon'], '?'); + $absolute_source_path = $projectRoot . DIRECTORY_SEPARATOR . $relative_source_path; + + // --- START DEBUGGING --- + if ($log_file) { + fwrite($log_file, " - DB Path: " . $bookmark['icon'] . "\n"); + fwrite($log_file, " - Relative Path Used: " . $relative_source_path . "\n"); + fwrite($log_file, " - Absolute Path Checked: " . $absolute_source_path . "\n"); + $file_exists_result = file_exists($absolute_source_path); + fwrite($log_file, " - file_exists() Result: " . ($file_exists_result ? 'TRUE' : 'FALSE') . "\n"); + } + // --- END DEBUGGING --- + + if ($file_exists_result) { + $filename = basename($relative_source_path); + + // The new path inside the zip archive's structure + $relative_dest_path = 'images/custom/' . $filename; + + // The absolute path to copy the file to in the temp directory + $absolute_dest_path = $temp_dir . DIRECTORY_SEPARATOR . $relative_dest_path; + + // Copy the file and if successful, update the path to be stored in the backup DB + if (copy($absolute_source_path, $absolute_dest_path)) { + $icon_path_for_db = $relative_dest_path; + if ($log_file) fwrite($log_file, " - ACTION: File copied successfully.\n\n"); + } else { + if ($log_file) fwrite($log_file, " - ACTION: copy() FAILED.\n\n"); + } + } else { + if ($log_file) fwrite($log_file, " - ACTION: File skipped.\n\n"); + } + } + $insert_book_stmt->execute([$bookmark['id'], $bookmark['category_id'], $bookmark['title'], $bookmark['url'], $icon_path_for_db, $bookmark['is_favorite'], $bookmark['sort_order'], $bookmark['tab_id']]); + } + + // 4. Create manifest.json + $manifest = [ + 'type' => 'tab', + 'tab_id' => $tab['id'], + 'tab_name' => $tab['name'], + 'version' => '2.0', + 'timestamp' => date('c') + ]; + file_put_contents($temp_dir . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT)); + + // 5. Zip and send the backup + $safe_tab_name = preg_replace('/[^a-zA-Z0-9_-]/', '_', $tab['name']); + $backupFileName = "bookmarkly_tab_{$safe_tab_name}_backup_" . date('Y-m-d') . '.zip'; + $backupFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $backupFileName; + + if (createZipArchive($temp_dir, $backupFilePath, $error_message)) { + // --- START DEBUGGING --- + if ($log_file) { + fwrite($log_file, "--- Backup zip created successfully. ---"); + fclose($log_file); + } + // --- END DEBUGGING --- + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $backupFileName . '"'); + header('Content-Length: ' . filesize($backupFilePath)); + readfile($backupFilePath); + unlink($backupFilePath); + rrmdir($temp_dir); + exit; + } else { + // --- START DEBUGGING --- + if ($log_file) { + fwrite($log_file, "--- FAILED to create zip archive. Error: " . $error_message . " ---"); + fclose($log_file); + } + // --- END DEBUGGING --- + throw new Exception('Failed to create zip archive. ' . $error_message); + } + + } catch (Exception $e) { + // --- START DEBUGGING --- + if (isset($log_file) && is_resource($log_file)) { + fwrite($log_file, "--- CATCH BLOCK ERROR: " . $e->getMessage() . " ---"); + fclose($log_file); + } + // --- END DEBUGGING --- + rrmdir($temp_dir); // Clean up on error + $_SESSION['flash_message'] = ['text' => 'Error creating tab backup: ' . $e->getMessage(), 'type' => 'danger']; + header('Location: ../settings.php'); + exit; + } +} diff --git a/api/clear_token.php b/api/clear_token.php new file mode 100644 index 0000000..6b0a2ed --- /dev/null +++ b/api/clear_token.php @@ -0,0 +1,30 @@ + 'You do not have permission to perform this action.']); + exit; +} + +$tab_id = $_POST['tab_id'] ?? null; + +if (!$tab_id) { + http_response_code(400); // Bad Request + echo json_encode(['error' => 'Tab ID is required.']); + exit; +} + +try { + // Set the token to NULL in the database + $stmt = $pdo->prepare("UPDATE tabs SET login_token = NULL WHERE id = ?"); + $stmt->execute([$tab_id]); + + echo json_encode(['success' => true]); +} catch (Exception $e) { + http_response_code(500); // Internal Server Error + echo json_encode(['error' => 'Failed to clear token: ' . $e->getMessage()]); +} diff --git a/api/fix_orphaned_bookmarks.php b/api/fix_orphaned_bookmarks.php new file mode 100644 index 0000000..8a0f77c --- /dev/null +++ b/api/fix_orphaned_bookmarks.php @@ -0,0 +1,39 @@ + false, 'message' => 'You do not have permission to perform this action.']); + exit; +} + +try { + // Find the default tab (first one by sort order or ID) to assign orphans to + $stmt = $pdo->query("SELECT id FROM tabs ORDER BY sort_order ASC, id ASC LIMIT 1"); + $default_tab_id = $stmt->fetchColumn(); + + if (!$default_tab_id) { + throw new Exception("No default tab found to assign bookmarks to."); + } + + $update_stmt = $pdo->prepare("UPDATE bookmarks SET tab_id = ? WHERE tab_id IS NULL OR tab_id = '' OR tab_id = 0"); + $update_stmt->execute([$default_tab_id]); + + $count = $update_stmt->rowCount(); + + if ($count > 0) { + $message = "Successfully assigned {$count} orphaned bookmarks to the default tab (ID: {$default_tab_id})."; + } else { + $message = "No orphaned bookmarks were found."; + } + + echo json_encode(['success' => true, 'message' => $message, 'updated_count' => $count]); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]); +} diff --git a/api/generate_token.php b/api/generate_token.php new file mode 100644 index 0000000..88bc55b --- /dev/null +++ b/api/generate_token.php @@ -0,0 +1,33 @@ + 'You do not have permission to perform this action.']); + exit; +} + +$tab_id = $_POST['tab_id'] ?? null; + +if (!$tab_id) { + http_response_code(400); // Bad Request + echo json_encode(['error' => 'Tab ID is required.']); + exit; +} + +try { + // Generate a unique token + $token = bin2hex(random_bytes(16)); + + // Store the token in the database + $stmt = $pdo->prepare("UPDATE tabs SET login_token = ? WHERE id = ?"); + $stmt->execute([$token, $tab_id]); + + echo json_encode(['success' => true, 'token' => $token]); +} catch (Exception $e) { + http_response_code(500); // Internal Server Error + echo json_encode(['error' => 'Failed to generate token: ' . $e->getMessage()]); +} diff --git a/api/import_backup.php b/api/import_backup.php new file mode 100644 index 0000000..322cfdc --- /dev/null +++ b/api/import_backup.php @@ -0,0 +1,205 @@ + false, + 'message' => 'A fatal server error occurred.', + 'error_details' => $error + ]); + } +}); + +require_once 'include/init.php'; +require_once 'include/auth.php'; + +header('Content-Type: application/json'); + +if (!isset($_SESSION['user_id'])) { + http_response_code(401); + echo json_encode(['success' => false, 'message' => 'You must be logged in.']); + exit; +} + +/** + * Recursively removes a directory and its contents. + * @param string $dir The directory to remove. + */ +function rrmdir($dir) { + if (is_dir($dir)) { + $objects = scandir($dir); + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + is_dir($dir . DIRECTORY_SEPARATOR . $object) ? rrmdir($dir . DIRECTORY_SEPARATOR . $object) : unlink($dir . DIRECTORY_SEPARATOR . $object); + } + } + rmdir($dir); + } +} + +// --- Main Logic --- +try { + if (!isset($_FILES['backup_file']) || $_FILES['backup_file']['error'] !== UPLOAD_ERR_OK) { + throw new Exception('File upload error.'); + } + if (!isset($_POST['target_tab_id']) || empty($_POST['target_tab_id'])) { + throw new Exception('Target tab not selected.'); + } + + $target_tab_id = (int)$_POST['target_tab_id']; + $backup_file = $_FILES['backup_file']['tmp_name']; + + $temp_dir = sys_get_temp_dir() . '/bookmarkly_import_' . uniqid(); + if (!mkdir($temp_dir, 0777, true)) { + throw new Exception('Failed to create temporary directory.'); + } + + $zip = new ZipArchive; + if ($zip->open($backup_file) !== TRUE) { + rrmdir($temp_dir); + throw new Exception('Failed to open the backup file.'); + } + + $zip->extractTo($temp_dir); + $zip->close(); + + $backup_db_path = $temp_dir . '/database/bookmarkly.db'; + $manifest_path = $temp_dir . '/manifest.json'; + + if (!file_exists($backup_db_path)) { + throw new Exception('Invalid backup file: bookmarkly.db not found.'); + } + + $backup_pdo = new PDO('sqlite:' . $backup_db_path); + $backup_pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $backup_pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + + // --- Begin Transaction --- + $pdo->beginTransaction(); + try { + $is_tab_backup = false; + if (file_exists($manifest_path)) { + $manifest = json_decode(file_get_contents($manifest_path), true); + if (isset($manifest['type']) && $manifest['type'] === 'tab') { + $is_tab_backup = true; + } + } + + // --- Import Logic --- + $old_to_new_category_id_map = []; + $imported_categories_count = 0; + $imported_bookmarks_count = 0; + + // If it's a tab backup, also import tab settings and files + if ($is_tab_backup) { + $backup_tab = $backup_pdo->query("SELECT * FROM tabs LIMIT 1")->fetch(); + if ($backup_tab) { + // Handle background image + $new_background_url = $backup_tab['background_image_url']; + if (!empty($new_background_url) && strpos($new_background_url, 'images/backgrounds/') === 0) { + $source_bg = $temp_dir . '/' . $new_background_url; + if (file_exists($source_bg)) { + $bg_filename = basename($new_background_url); + $dest_dir = 'data/images/backgrounds/'; + if (!is_dir($dest_dir)) mkdir($dest_dir, 0755, true); + + $unique_filename = 'tab' . $target_tab_id . '_' . time() . '_' . $bg_filename; + $dest_bg = $dest_dir . $unique_filename; + if (copy($source_bg, $dest_bg)) { + $new_background_url = $dest_bg . '?v=' . time(); + } + } + } + + // Update target tab with settings from backup + $update_tab_stmt = $pdo->prepare( + "UPDATE tabs SET theme = ?, theme_transparency = ?, background_image_url = ?, background_brightness = ?, background_saturation = ? WHERE id = ?" + ); + $update_tab_stmt->execute([ + $backup_tab['theme'], + $backup_tab['theme_transparency'], + $new_background_url, + $backup_tab['background_brightness'], + $backup_tab['background_saturation'], + $target_tab_id + ]); + } + } + + // Import Categories + $categories = $backup_pdo->query("SELECT * FROM categories")->fetchAll(); + $stmt_insert_category = $pdo->prepare("INSERT INTO categories (name, sort_order, tab_id) VALUES (?, ?, ?)"); + foreach ($categories as $category) { + $stmt_insert_category->execute([$category['name'], $category['sort_order'], $target_tab_id]); + $old_to_new_category_id_map[$category['id']] = $pdo->lastInsertId(); + $imported_categories_count++; + } + + // Import Bookmarks + $bookmarks = $backup_pdo->query("SELECT * FROM bookmarks")->fetchAll(); + $stmt_insert_bookmark = $pdo->prepare("INSERT INTO bookmarks (category_id, title, url, icon, is_favorite, sort_order, tab_id) VALUES (?, ?, ?, ?, ?, ?, ?)"); + foreach ($bookmarks as $bookmark) { + // Determine the new category ID, allowing for bookmarks without a category + $new_category_id = null; + if (!empty($bookmark['category_id']) && isset($old_to_new_category_id_map[$bookmark['category_id']])) { + $new_category_id = $old_to_new_category_id_map[$bookmark['category_id']]; + } + + // Handle icon + $new_icon_path = $bookmark['icon']; + // In tab backups, icon paths are relative to the zip root, e.g., 'images/custom/icon.png' + if ($is_tab_backup && !empty($new_icon_path) && strpos($new_icon_path, 'images/custom/') === 0) { + $source_icon = $temp_dir . '/' . $new_icon_path; + if (file_exists($source_icon)) { + $icon_filename = basename($new_icon_path); + $dest_dir = 'data/images/custom/'; // The correct destination for custom icons + if (!is_dir($dest_dir)) mkdir($dest_dir, 0755, true); + + $unique_filename = 'imported_' . time() . '_' . $icon_filename; + $dest_icon = $dest_dir . $unique_filename; + if (copy($source_icon, $dest_icon)) { + $new_icon_path = $dest_icon; + } + } + } + + $stmt_insert_bookmark->execute([ + $new_category_id, $bookmark['title'], $bookmark['url'], + $new_icon_path, $bookmark['is_favorite'], $bookmark['sort_order'], $target_tab_id + ]); + $imported_bookmarks_count++; + } + + $pdo->commit(); + $message = "Import successful. Added {$imported_categories_count} categories and {$imported_bookmarks_count} bookmarks."; + echo json_encode(['success' => true, 'message' => $message]); + + } catch (Exception $e) { + $pdo->rollBack(); + throw new Exception('Database error during import: ' . $e->getMessage()); + } + +} catch (Exception $e) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + +} finally { + if (isset($temp_dir) && is_dir($temp_dir)) { + rrmdir($temp_dir); + } + // Close DB connections + $pdo = null; + if(isset($backup_pdo)) { + $backup_pdo = null; + } +} diff --git a/api/optimize_image.php b/api/optimize_image.php new file mode 100644 index 0000000..0814124 --- /dev/null +++ b/api/optimize_image.php @@ -0,0 +1,123 @@ + false, 'message' => 'Authentication required.']); + exit; +} + +// Check if image path is provided +if (!isset($_POST['path']) || empty($_POST['path'])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Image path not provided.']); + exit; +} + +$imagePath = $_POST['path']; + +// Sanitize the path: remove any query string to prevent issues with realpath() +$path_parts = parse_url($imagePath); +$imagePath = $path_parts['path']; + +$baseDir = realpath(__DIR__ . '/../data/images/backgrounds'); +$fullPath = realpath(__DIR__ . '/../' . $imagePath); + +// Security check: ensure the file is within the backgrounds directory +if (!$fullPath || strpos($fullPath, $baseDir) !== 0 || !file_exists($fullPath)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Invalid or non-existent image path.']); + exit; +} + +// --- Image Optimization Logic --- +$maxWidth = 1920; +$quality = 85; + +try { + // Get image info + list($width, $height, $type) = getimagesize($fullPath); + + // Don't optimize if the image is already reasonably sized + if ($width <= $maxWidth) { + // Still, re-save JPEG to apply quality setting, which might reduce size + if ($type == IMAGETYPE_JPEG) { + $source = imagecreatefromjpeg($fullPath); + imagejpeg($source, $fullPath, $quality); + imagedestroy($source); + echo json_encode(['success' => true, 'message' => 'Image quality adjusted, but no resize needed.']); + exit; + } + // For other types, if no resize is needed, there's nothing to do. + echo json_encode(['success' => true, 'message' => 'Image is already optimized (width is within limit).']); + exit; + } + + // Continue with resizing for images wider than maxWidth + $newWidth = $maxWidth; + $newHeight = (int) (($newWidth / $width) * $height); + + // Create image resource based on type + switch ($type) { + case IMAGETYPE_JPEG: + $source = imagecreatefromjpeg($fullPath); + break; + case IMAGETYPE_PNG: + $source = imagecreatefrompng($fullPath); + break; + case IMAGETYPE_GIF: + $source = imagecreatefromgif($fullPath); + break; + case IMAGETYPE_WEBP: + $source = imagecreatefromwebp($fullPath); + break; + default: + throw new Exception('Unsupported image type.'); + } + + if ($source === false) { + throw new Exception('Could not create image resource.'); + } + + // Create a new true color image + $thumb = imagecreatetruecolor($newWidth, $newHeight); + + // Handle transparency for PNG + if ($type == IMAGETYPE_PNG) { + imagealphablending($thumb, false); + imagesavealpha($thumb, true); + $transparent = imagecolorallocatealpha($thumb, 255, 255, 255, 127); + imagefilledrectangle($thumb, 0, 0, $newWidth, $newHeight, $transparent); + } + + // Resize + imagecopyresampled($thumb, $source, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); + + // Save the optimized image, overwriting the original + switch ($type) { + case IMAGETYPE_JPEG: + imagejpeg($thumb, $fullPath, $quality); + break; + case IMAGETYPE_PNG: + // PNG quality is 0-9. 85 quality -> level 1-2. + imagepng($thumb, $fullPath, 1); + break; + case IMAGETYPE_GIF: + imagegif($thumb, $fullPath); + break; + case IMAGETYPE_WEBP: + imagewebp($thumb, $fullPath, $quality); + break; + } + + // Free up memory + imagedestroy($source); + imagedestroy($thumb); + + echo json_encode(['success' => true, 'message' => 'Image optimized successfully.']); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Error optimizing image: ' . $e->getMessage()]); +} diff --git a/api/pexels.php b/api/pexels.php new file mode 100644 index 0000000..c301ae0 --- /dev/null +++ b/api/pexels.php @@ -0,0 +1,42 @@ +prepare("SELECT value FROM settings WHERE key = ?"); +$stmt->execute(['pexels_api_key']); +$api_key = $stmt->fetchColumn(); + +if (empty($api_key)) { + echo json_encode(['error' => 'Pexels API key is not set in settings.']); + exit; +} + +$query = trim($_GET['query'] ?? ''); +if (empty($query)) { + echo json_encode(['error' => 'Search query is required.']); + exit; +} + +$url = "https://api.pexels.com/v1/search?query=" . urlencode($query) . "&per_page=15"; + +$ch = curl_init(); +curl_setopt($ch, CURLOPT_URL, $url); +curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); +curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: ' . $api_key +]); + +$result = curl_exec($ch); +$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($http_code !== 200) { + echo json_encode(['error' => 'Failed to fetch images from Pexels.', 'details' => json_decode($result)]); + exit; +} + +echo $result; diff --git a/api/tab_access.php b/api/tab_access.php new file mode 100644 index 0000000..41c3ebf --- /dev/null +++ b/api/tab_access.php @@ -0,0 +1,68 @@ + 'Permission denied.']); + exit; +} + +header('Content-Type: application/json'); +$method = $_SERVER['REQUEST_METHOD']; +$response = []; + +try { + if ($method === 'GET') { + $tab_id = filter_input(INPUT_GET, 'tab_id', FILTER_VALIDATE_INT); + if ($tab_id) { + $stmt = $pdo->prepare("SELECT user_id FROM tab_viewer_access WHERE tab_id = ?"); + $stmt->execute([$tab_id]); + // Fetch just the user_id column into a simple array + $response = $stmt->fetchAll(PDO::FETCH_COLUMN, 0); + } else { + http_response_code(400); // Bad Request + $response = ['error' => 'Tab ID is required.']; + } + } elseif ($method === 'POST') { + $data = json_decode(file_get_contents('php://input'), true); + $tab_id = $data['tab_id'] ?? null; + $viewer_ids = $data['viewer_ids'] ?? []; + + if ($tab_id && is_array($viewer_ids)) { + // Start transaction + $pdo->beginTransaction(); + + // 1. Delete existing entries for this tab + $delete_stmt = $pdo->prepare("DELETE FROM tab_viewer_access WHERE tab_id = ?"); + $delete_stmt->execute([$tab_id]); + + // 2. Insert new entries + if (!empty($viewer_ids)) { + $insert_stmt = $pdo->prepare("INSERT INTO tab_viewer_access (tab_id, user_id) VALUES (?, ?)"); + foreach ($viewer_ids as $user_id) { + $insert_stmt->execute([$tab_id, $user_id]); + } + } + + // Commit transaction + $pdo->commit(); + $response = ['success' => true]; + } else { + http_response_code(400); // Bad Request + $response = ['error' => 'Tab ID and a list of viewer IDs are required.']; + } + } else { + http_response_code(405); // Method Not Allowed + $response = ['error' => 'Method not allowed']; + } +} catch (PDOException $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + http_response_code(500); // Internal Server Error + $response = ['error' => 'Database error: ' . $e->getMessage()]; +} + +echo json_encode($response); diff --git a/api/tab_visibility.php b/api/tab_visibility.php new file mode 100644 index 0000000..28c359f --- /dev/null +++ b/api/tab_visibility.php @@ -0,0 +1,52 @@ + 'Permission denied.']); + exit; +} + +header('Content-Type: application/json'); +$method = $_SERVER['REQUEST_METHOD']; +$response = []; + +try { + if ($method === 'GET') { + // Get all hidden tab settings + $stmt = $pdo->query("SELECT user_id, tab_id FROM user_tab_visibility"); + $response = $stmt->fetchAll(); + + } elseif ($method === 'POST') { + $data = json_decode(file_get_contents('php://input'), true); + $tab_id = $data['tab_id'] ?? null; + $user_id = $data['user_id'] ?? null; + $is_visible = $data['is_visible'] ?? true; + + if ($tab_id && $user_id) { + if ($is_visible) { + // If it should be visible, DELETE the entry from the hide table + $stmt = $pdo->prepare("DELETE FROM user_tab_visibility WHERE user_id = ? AND tab_id = ?"); + $stmt->execute([$user_id, $tab_id]); + } else { + // If it should be hidden, INSERT an entry (ignore if it already exists) + $stmt = $pdo->prepare("INSERT OR IGNORE INTO user_tab_visibility (user_id, tab_id) VALUES (?, ?)"); + $stmt->execute([$user_id, $tab_id]); + } + $response = ['success' => true]; + } else { + http_response_code(400); // Bad Request + $response = ['error' => 'Tab ID and User ID are required.']; + } + } else { + http_response_code(405); // Method Not Allowed + $response = ['error' => 'Method not allowed']; + } +} catch (PDOException $e) { + http_response_code(500); // Internal Server Error + $response = ['error' => 'Database error: ' . $e->getMessage()]; +} + +echo json_encode($response); diff --git a/api/tabs.php b/api/tabs.php new file mode 100644 index 0000000..9132e6e --- /dev/null +++ b/api/tabs.php @@ -0,0 +1,105 @@ +query("SELECT * FROM tabs ORDER BY id"); + $response = $stmt->fetchAll(); + break; + + case 'POST': + // Create a new tab + $data = json_decode(file_get_contents('php://input'), true); + if (isset($data['name'])) { + $stmt = $pdo->prepare("INSERT INTO tabs (name, user_id) VALUES (?, ?)"); + $stmt->execute([$data['name'], $data['user_id'] ?? null]); + $response = ['id' => $pdo->lastInsertId(), 'name' => $data['name']]; + } else { + http_response_code(400); // Bad Request + $response = ['error' => 'Name is required']; + } + break; + + case 'PUT': + // Update a tab + $data = json_decode(file_get_contents('php://input'), true); + if (isset($data['id']) && isset($data['name'])) { + // Prepare fields to update + $fields = [ + 'name' => $data['name'], + 'user_id' => $data['user_id'] ?? null, + 'background_image_url' => $data['background_image_url'] ?? null, + 'background_brightness' => $data['background_brightness'] ?? 100, + 'background_saturation' => $data['background_saturation'] ?? 100, + 'theme' => $data['theme'] ?? null, + 'theme_transparency' => $data['theme_transparency'] ?? null, + 'column_count' => $data['column_count'] ?? 5, + ]; + + // Handle empty strings to set them to NULL for proper fallback + if ($fields['background_image_url'] === '') { + $fields['background_image_url'] = null; + } + if ($fields['theme'] === '') { + $fields['theme'] = null; + } + if ($fields['theme_transparency'] === '' || !is_numeric($fields['theme_transparency'])) { + $fields['theme_transparency'] = null; + } + + $stmt = $pdo->prepare(" + UPDATE tabs + SET + name = :name, + user_id = :user_id, + background_image_url = :background_image_url, + background_brightness = :background_brightness, + background_saturation = :background_saturation, + theme = :theme, + theme_transparency = :theme_transparency, + column_count = :column_count + WHERE id = :id + "); + + $fields['id'] = $data['id']; + $stmt->execute($fields); + + $response = ['success' => true]; + } else { + http_response_code(400); // Bad Request + $response = ['error' => 'ID and name are required']; + } + break; + + case 'DELETE': + // Delete a tab + $data = json_decode(file_get_contents('php://input'), true); + if (isset($data['id'])) { + $stmt = $pdo->prepare("DELETE FROM tabs WHERE id = ?"); + $stmt->execute([$data['id']]); + $response = ['success' => true]; + } else { + http_response_code(400); // Bad Request + $response = ['error' => 'ID is required']; + } + break; + + default: + http_response_code(405); // Method Not Allowed + $response = ['error' => 'Method not allowed']; + break; + } +} catch (PDOException $e) { + http_response_code(500); // Internal Server Error + $response = ['error' => 'Database error: ' . $e->getMessage()]; +} + +echo json_encode($response); diff --git a/api/update_database.php b/api/update_database.php new file mode 100644 index 0000000..accd9c2 --- /dev/null +++ b/api/update_database.php @@ -0,0 +1,89 @@ + false, 'message' => 'You do not have permission to perform this action.']); + exit; +} + +try { + $messages = []; + + // --- Check columns in 'tabs' table --- + $tabs_columns = $pdo->query("PRAGMA table_info(tabs)")->fetchAll(PDO::FETCH_COLUMN, 1); + + $required_columns = [ + 'background_image_url' => 'TEXT DEFAULT NULL', + 'background_brightness' => 'INTEGER DEFAULT 100', + 'background_saturation' => 'INTEGER DEFAULT 100', + 'theme' => 'TEXT DEFAULT NULL', + 'theme_transparency' => 'INTEGER DEFAULT NULL', + 'login_token' => 'VARCHAR(255)' + ]; + + $columns_added = false; + foreach ($required_columns as $column_name => $column_definition) { + if (!in_array($column_name, $tabs_columns)) { + $pdo->exec("ALTER TABLE tabs ADD COLUMN $column_name $column_definition"); + $messages[] = "Column '$column_name' added to 'tabs' table."; + $columns_added = true; + } else { + $messages[] = "Column '$column_name' already exists in 'tabs' table."; + } + } + + // Check for login_token column and add unique index if not present + $current_tabs_columns = $pdo->query("PRAGMA table_info(tabs)")->fetchAll(PDO::FETCH_COLUMN, 1); + if (in_array('login_token', $current_tabs_columns)) { + $indexes = $pdo->query("PRAGMA index_list(tabs)")->fetchAll(); + $index_exists = false; + foreach ($indexes as $index) { + // Check if the index is on the login_token column + $index_info = $pdo->query("PRAGMA index_info({$index['name']})")->fetchAll(); + foreach ($index_info as $info) { + if ($info['name'] === 'login_token' && $index['unique']) { + $index_exists = true; + break 2; + } + } + } + + if (!$index_exists) { + $tabs_without_token = $pdo->query("SELECT id FROM tabs WHERE login_token IS NULL OR login_token = ''")->fetchAll(PDO::FETCH_COLUMN); + if (!empty($tabs_without_token)) { + $update_stmt = $pdo->prepare("UPDATE tabs SET login_token = ? WHERE id = ?"); + foreach ($tabs_without_token as $tab_id) { + $token = bin2hex(random_bytes(16)); + $update_stmt->execute([$token, $tab_id]); + } + $messages[] = count($tabs_without_token) . " existing tabs updated with a unique login token."; + $columns_added = true; + } + + try { + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tabs_login_token ON tabs(login_token)"); + $messages[] = "Added unique index to login_token column."; + $columns_added = true; + } catch (PDOException $e) { + $messages[] = "Could not create unique index on login_token: " . $e->getMessage(); + } + } else { + $messages[] = "Unique index on login_token already exists."; + } + } + + if ($columns_added) { + echo json_encode(['success' => true, 'message' => 'Database updated successfully!', 'details' => $messages]); + } else { + echo json_encode(['success' => true, 'message' => 'Database is already up to date.', 'details' => $messages]); + } + +} catch (PDOException $e) { + http_response_code(500); // Internal Server Error + echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]); +} diff --git a/api/update_order.php b/api/update_order.php new file mode 100644 index 0000000..1c2dcb4 --- /dev/null +++ b/api/update_order.php @@ -0,0 +1,47 @@ + 'Invalid input.']); + exit; +} + +$type = $input['type']; +$order = $input['order']; +$table_name = ''; + +if ($type === 'category') { + $table_name = 'categories'; +} elseif ($type === 'bookmark') { + $table_name = 'bookmarks'; +} else { + http_response_code(400); + echo json_encode(['error' => 'Invalid type specified.']); + exit; +} + +try { + $pdo->beginTransaction(); + + $stmt = $pdo->prepare("UPDATE {$table_name} SET sort_order = ? WHERE id = ?"); + + foreach ($order as $index => $id) { + if (is_numeric($id)) { + $stmt->execute([$index + 1, $id]); + } + } + + $pdo->commit(); + echo json_encode(['success' => true, 'message' => ucfirst($type) . ' order updated.']); + +} catch (Exception $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); +} diff --git a/api/update_sort.php b/api/update_sort.php new file mode 100644 index 0000000..a2d4e28 --- /dev/null +++ b/api/update_sort.php @@ -0,0 +1,47 @@ + 'Invalid input.']); + exit; +} + +$type = $data['type']; +$order = $data['order']; + +if (!is_array($order)) { + http_response_code(400); + echo json_encode(['error' => 'Order data must be an array.']); + exit; +} + +$table = ''; +if ($type === 'category') { + $table = 'categories'; +} elseif ($type === 'bookmark') { + $table = 'bookmarks'; +} else { + http_response_code(400); + echo json_encode(['error' => 'Invalid type specified.']); + exit; +} + +try { + $pdo->beginTransaction(); + $stmt = $pdo->prepare("UPDATE {$table} SET sort_order = ? WHERE id = ?"); + foreach ($order as $index => $id) { + if (!is_numeric($id)) continue; // Basic sanitization + $stmt->execute([$index + 1, $id]); + } + $pdo->commit(); + echo json_encode(['success' => true]); +} catch (Exception $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['error' => 'Database update failed.', 'details' => $e->getMessage()]); +} diff --git a/api/upload_tab_background.php b/api/upload_tab_background.php new file mode 100644 index 0000000..6d822d2 --- /dev/null +++ b/api/upload_tab_background.php @@ -0,0 +1,96 @@ + false, 'message' => 'You do not have permission to perform this action.']); + exit; +} + +header('Content-Type: application/json'); + +$response = [ + 'success' => false, + 'message' => 'An unknown error occurred.', + 'path' => null +]; + +$uploadDir = '../data/images/backgrounds/'; + +// Create directory if it doesn't exist +if (!is_dir($uploadDir)) { + if (!mkdir($uploadDir, 0755, true)) { + http_response_code(500); + $response['message'] = 'Failed to create upload directory.'; + echo json_encode($response); + exit; + } +} + +if (isset($_FILES['background']) && $_FILES['background']['error'] === UPLOAD_ERR_OK) { + $file = $_FILES['background']; + $tabId = isset($_POST['tab_id']) ? intval($_POST['tab_id']) : 0; + + // --- Validation --- + // Size check (e.g., 5MB limit) + if ($file['size'] > 5 * 1024 * 1024) { + http_response_code(400); + $response['message'] = 'File is too large. Maximum size is 5MB.'; + echo json_encode($response); + exit; + } + + // Type check + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $fileInfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($fileInfo, $file['tmp_name']); + finfo_close($fileInfo); + + if (!in_array($mimeType, $allowedTypes)) { + http_response_code(400); + $response['message'] = 'Invalid file type. Only JPG, PNG, GIF, and WEBP are allowed.'; + echo json_encode($response); + exit; + } + + // --- File Processing --- + // Find and delete any existing background for this tab + $existing_files = glob($uploadDir . 'tab_' . $tabId . '.*'); + foreach ($existing_files as $file_to_delete) { + unlink($file_to_delete); + } + + $extension = pathinfo($file['name'], PATHINFO_EXTENSION); + $fileName = 'tab_' . $tabId . '.' . $extension; + $destination = $uploadDir . $fileName; + + if (move_uploaded_file($file['tmp_name'], $destination)) { + $response['success'] = true; + $response['message'] = 'File uploaded successfully.'; + // Return the path relative to the project root with a cache-busting query string + $response['path'] = 'data/images/backgrounds/' . $fileName . '?v=' . time(); + } else { + http_response_code(500); + $response['message'] = 'Failed to move uploaded file.'; + } + +} else { + http_response_code(400); + switch ($_FILES['background']['error'] ?? UPLOAD_ERR_NO_FILE) { + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + $response['message'] = 'File is too large.'; + break; + case UPLOAD_ERR_NO_FILE: + $response['message'] = 'No file was uploaded.'; + break; + default: + $response['message'] = 'An error occurred during upload.'; + break; + } +} + +echo json_encode($response); diff --git a/bookmarks.php b/bookmarks.php new file mode 100644 index 0000000..a3a4c9f --- /dev/null +++ b/bookmarks.php @@ -0,0 +1,677 @@ +prepare("SELECT icon FROM bookmarks WHERE id = ?"); + $stmt->execute([$bookmark_id]); + $old_icon = $stmt->fetchColumn(); + // Check if old icon exists, is custom, and is not the same path as the new one + if ($old_icon && file_exists($old_icon) && strpos($old_icon, 'data/images/custom/') === 0 && $old_icon !== $destination) { + unlink($old_icon); + } + } + + if (move_uploaded_file($_FILES['icon_upload']['tmp_name'], $destination)) { + return $destination; // Return the path to be saved in DB + } + } + } + return null; // No upload or failed upload +} + + +// Handle POST requests for CRUD operations +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // Check edit permissions for the submitted tab_id for all actions + $submitted_tab_id = $_POST['tab_id'] ?? $_GET['tab_id'] ?? null; + if (isset($_POST['action']) && !canEditTab($submitted_tab_id)) { + $_SESSION['flash_message'] = ['text' => 'You do not have permission to perform this action.', 'type' => 'danger']; + header('Location: bookmarks.php?tab_id=' . $submitted_tab_id); + exit; + } + + $action = $_POST['action'] ?? ''; + + // --- Category Actions --- + if ($action === 'add_category') { + $category_name = trim($_POST['category_name'] ?? ''); + $tab_id = filter_input(INPUT_POST, 'tab_id', FILTER_VALIDATE_INT); + if (!empty($category_name) && $tab_id) { + $sort_stmt = $pdo->prepare("SELECT MAX(sort_order) as max_sort FROM categories WHERE tab_id = ?"); + $sort_stmt->execute([$tab_id]); + $max_sort = $sort_stmt->fetchColumn() ?: 0; + + $stmt = $pdo->prepare("INSERT INTO categories (name, sort_order, tab_id) VALUES (?, ?, ?)"); + if ($stmt->execute([$category_name, $max_sort + 1, $tab_id])) { + $_SESSION['flash_message'] = ['text' => 'Category added successfully.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error adding category.', 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Category name and tab ID are required.', 'type' => 'warning']; + } + } elseif ($action === 'delete_category') { + $category_id = filter_input(INPUT_POST, 'category_id', FILTER_VALIDATE_INT); + if ($category_id) { + $stmt = $pdo->prepare("DELETE FROM categories WHERE id = ?"); + if ($stmt->execute([$category_id])) { + $_SESSION['flash_message'] = ['text' => 'Category and its bookmarks have been deleted.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error deleting category.', 'type' => 'danger']; + } + } + } elseif ($action === 'edit_category') { + $category_id = filter_input(INPUT_POST, 'category_id', FILTER_VALIDATE_INT); + $category_name = trim($_POST['category_name'] ?? ''); + if ($category_id && !empty($category_name)) { + $stmt = $pdo->prepare("UPDATE categories SET name = ? WHERE id = ?"); + if ($stmt->execute([$category_name, $category_id])) { + $_SESSION['flash_message'] = ['text' => 'Category renamed successfully.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error renaming category.', 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Invalid data for renaming category.', 'type' => 'warning']; + } + } + + // --- Bookmark Actions --- + elseif ($action === 'add_bookmark') { + $title = trim($_POST['title'] ?? ''); + $url = trim($_POST['url'] ?? ''); + if (!empty($url) && !preg_match('/^https?:\/\//i', $url)) { + $url = 'https://' . $url; + } + $category_id = filter_input(INPUT_POST, 'category_id', FILTER_VALIDATE_INT); + $icon_url = trim($_POST['icon_url'] ?? ''); + $is_favorite = isset($_POST['is_favorite']) ? 1 : 0; + + $icon = $icon_url; // Default to URL + $new_icon_path = handle_icon_upload(); + if ($new_icon_path) { + $icon = $new_icon_path; + } + + if ($title && $url && $category_id) { + $sort_stmt = $pdo->prepare("SELECT MAX(sort_order) as max_sort FROM bookmarks WHERE category_id = ?"); + $sort_stmt->execute([$category_id]); + $max_sort = $sort_stmt->fetchColumn() ?: 0; + + $stmt = $pdo->prepare( + "INSERT INTO bookmarks (category_id, title, url, icon, is_favorite, sort_order) + VALUES (?, ?, ?, ?, ?, ?)" + ); + if ($stmt->execute([$category_id, $title, $url, $icon, $is_favorite, $max_sort + 1])) { + $bookmark_id = $pdo->lastInsertId(); // Get the ID of the new bookmark + + // Handle icon upload after getting the ID + $new_icon_path = handle_icon_upload($bookmark_id, $title); + if ($new_icon_path) { + // Update the bookmark with the new icon path + $update_stmt = $pdo->prepare("UPDATE bookmarks SET icon = ? WHERE id = ?"); + $update_stmt->execute([$new_icon_path, $bookmark_id]); + } + + $_SESSION['flash_message'] = ['text' => 'Bookmark added successfully.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error adding bookmark.', 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Title, URL, and category are required.', 'type' => 'warning']; + } + } elseif ($action === 'delete_bookmark') { + $bookmark_id = filter_input(INPUT_POST, 'bookmark_id', FILTER_VALIDATE_INT); + if ($bookmark_id) { + $stmt = $pdo->prepare("DELETE FROM bookmarks WHERE id = ?"); + if ($stmt->execute([$bookmark_id])) { + $_SESSION['flash_message'] = ['text' => 'Bookmark deleted.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error deleting bookmark.', 'type' => 'danger']; + } + } + } + elseif ($action === 'edit_bookmark') { + $bookmark_id = filter_input(INPUT_POST, 'bookmark_id', FILTER_VALIDATE_INT); + $title = trim($_POST['title'] ?? ''); + $url = trim($_POST['url'] ?? ''); + if (!empty($url) && !preg_match('/^https?:\/\//i', $url)) { + $url = 'https://' . $url; + } + $category_id = filter_input(INPUT_POST, 'category_id', FILTER_VALIDATE_INT); + $icon_url = trim($_POST['icon_url'] ?? ''); + $is_favorite = isset($_POST['is_favorite']) ? 1 : 0; + $remove_icon = ($_POST['remove_icon'] ?? '0') === '1'; + + if ($bookmark_id && $title && $url && $category_id) { + // Fetch current icon to manage file deletion + $stmt = $pdo->prepare("SELECT icon FROM bookmarks WHERE id = ?"); + $stmt->execute([$bookmark_id]); + $current_icon = $stmt->fetchColumn(); + + $icon = $current_icon; // Keep old icon by default + + $new_icon_path = handle_icon_upload($bookmark_id, $title); + + if ($new_icon_path) { + // 1. New upload takes highest precedence + $icon = $new_icon_path; + } else if ($remove_icon) { + // 2. Remove icon takes next precedence + if ($current_icon && file_exists($current_icon) && strpos($current_icon, 'data/images/custom/') === 0) { + unlink($current_icon); + } + // After removing, use whatever is in the URL field (might be empty) + $icon = $icon_url; + } else { + // 3. No upload, no removal. Handle URL input field. + // If the current icon is NOT a custom file, we can just save the icon_url. + if (strpos($current_icon ?? '', 'data/images/custom/') !== 0) { + $icon = $icon_url; + } + // If it IS a custom file, we should only change it if the user provided a new icon_url. + else if (!empty($icon_url)) { + // User is switching from custom to URL. Delete the old file. + if (file_exists($current_icon)) { + unlink($current_icon); + } + $icon = $icon_url; + } + // Implicit else: current icon is custom, icon_url is empty. Do nothing to keep the custom icon. + } + + $stmt = $pdo->prepare( + "UPDATE bookmarks SET title = ?, url = ?, category_id = ?, icon = ?, is_favorite = ? WHERE id = ?" + ); + if ($stmt->execute([$title, $url, $category_id, $icon, $is_favorite, $bookmark_id])) { + $_SESSION['flash_message'] = ['text' => 'Bookmark updated successfully.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error updating bookmark.', 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Invalid data for updating bookmark.', 'type' => 'warning']; + } + } + + + // Redirect to the same page to prevent form resubmission, preserving the tab + $tab_id = $_POST['tab_id'] ?? $_GET['tab_id'] ?? 1; + header('Location: bookmarks.php?tab_id=' . $tab_id); + exit; +} + + +// Fetch visible tabs for the user +$current_user_id = $_SESSION['user_id'] ?? null; +$current_user_role = $_SESSION['role'] ?? null; + +$tabs_stmt_sql = "SELECT DISTINCT t.* FROM tabs t "; +$params = []; + +// Logic to determine which tabs are visible based on role and explicit permissions +$visibility_conditions = " + LEFT JOIN user_tab_visibility utv ON t.id = utv.tab_id AND utv.user_id = :current_user_id + WHERE utv.user_id IS NULL +"; + +if ($current_user_id) { + if ($current_user_role === 'admin') { + // Admin sees all tabs that are not explicitly hidden for them + $tabs_stmt_sql .= $visibility_conditions; + } else { // 'user' and 'viewer' roles + // Users/Viewers see public tabs, their own tabs, or tabs they have access to, + // as long as they are not explicitly hidden. + $tabs_stmt_sql .= " + LEFT JOIN tab_viewer_access tva ON t.id = tva.tab_id + {$visibility_conditions} + AND (t.user_id IS NULL OR t.user_id = :current_user_id OR tva.user_id = :current_user_id) + "; + } + $params[':current_user_id'] = $current_user_id; +} else { + // Guest sees only public tabs + $tabs_stmt_sql .= "WHERE t.user_id IS NULL"; +} + +$tabs_stmt_sql .= " ORDER BY t.id"; + +$tabs_stmt = $pdo->prepare($tabs_stmt_sql); +$tabs_stmt->execute($params); +$tabs = $tabs_stmt->fetchAll(); + +// Fetch all categories and their bookmarks for display for the current tab +$current_tab_id = $_GET['tab_id'] ?? ($tabs[0]['id'] ?? 1); + +// Check if the current user can edit the currently viewed tab +$can_edit = canEditTab($current_tab_id); + +$stmt = $pdo->prepare(" + SELECT + c.id as category_id, + c.name as category_name, + c.sort_order as category_sort_order, + b.id as bookmark_id, + b.title as bookmark_title, + b.url as bookmark_url, + b.icon as bookmark_icon, + b.is_favorite as bookmark_is_favorite, + b.sort_order as bookmark_sort_order + FROM categories c + LEFT JOIN bookmarks b ON c.id = b.category_id + WHERE c.tab_id = :tab_id + ORDER BY c.sort_order, c.name, b.sort_order, b.title +"); +$stmt->execute(['tab_id' => $current_tab_id]); + +$results = $stmt->fetchAll(); + +$categories = []; +foreach ($results as $row) { + if (!isset($categories[$row['category_id']])) { + $categories[$row['category_id']] = [ + 'id' => $row['category_id'], + 'name' => $row['category_name'], + 'bookmarks' => [] + ]; + } + if ($row['bookmark_id']) { + $categories[$row['category_id']]['bookmarks'][] = [ + 'id' => $row['bookmark_id'], + 'title' => $row['bookmark_title'], + 'url' => $row['bookmark_url'], + 'icon' => $row['bookmark_icon'], + 'is_favorite' => $row['bookmark_is_favorite'] + ]; + } +} + +require_once 'include/header.php'; +?> +