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. + +![Screenshot](https://nedshare.nl/u/lWKywO.png) + +## 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'; +?> +
+
+

+
+ + + Manage Tabs + + + + + +
+
+ + + 1): ?> + + + + + + + +
+ +
+

+
+
+ + +
+ + +
+ + +
+
+ + +
    + +
  • +
    + + +
    +
    + + + +
    + + + + +
    + +
    +
  • + +
+
+
+
+ + +
+
+

+
+ + +
+
+

+
+
+ + + +
+
+ +
+
+ +

+ +
    + + +
  • + + + + > + + + + +
    + + +
    + + + + +
    + +
    +
  • + +
+ +
+
+ +
+
+
+ + + + + + + + + + + diff --git a/data/database/.htaccess b/data/database/.htaccess new file mode 100644 index 0000000..8585aaa --- /dev/null +++ b/data/database/.htaccess @@ -0,0 +1,3 @@ + + Require all denied + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..10c26b6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + php: + build: . + container_name: bookmarkly2 + restart: unless-stopped + ports: + - "8080:80" + volumes: + - .:/var/www/html:rw + environment: + TZ: "Europe/Amsterdam" + PHP_ERRORS: 1 + PHP_MEM_LIMIT: 128 + PHP_POST_MAX_SIZE: 250 + PHP_UPLOAD_MAX_FILESIZE: 25 + PHP_MAX_FILE_UPLOADS: 20 + CREATE_PHPINFO_FILE: 0 + CREATE_INDEX_FILE: 0 + ENABLE_APACHE_REWRITE: 1 + ENABLE_APACHE_ACTIONS: 0 + ENABLE_APACHE_SSL: 0 + ENABLE_APACHE_HEADERS: 0 + ENABLE_APACHE_ALLOWOVERRIDE: 1 + ENABLE_APACHE_REMOTEIP: 0 + ENABLE_APACHE_STATUS: 0 +# favicon-downloader: +# ports: +# - 3000:3000 +# image: seadfeng/favicon-downloader +# restart: always \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..61e444d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +# Set permissions on the data directory +chown -R www-data:www-data /var/www/html/data +chmod -R 775 /var/www/html/data + +# Execute the CMD from the Dockerfile, or the command passed to `docker run` +exec "$@" diff --git a/include/auth.php b/include/auth.php new file mode 100644 index 0000000..a4755fa --- /dev/null +++ b/include/auth.php @@ -0,0 +1,92 @@ +query("SELECT value FROM settings WHERE key = 'dashboard_protected'"); + $dashboard_protected = $stmt->fetchColumn(); + if ($dashboard_protected === '1') { + $auth_required = true; + } + } catch (Exception $e) { + // If the setting can't be read, default to requiring authentication for safety + $auth_required = true; + } +} + +// If authentication is required for the page, check for a valid session +if ($auth_required && !isset($_SESSION['user_id']) && !isset($_SESSION['guest_tab_id'])) { + // For API calls, return a JSON error instead of redirecting + if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') { + header('Content-Type: application/json'); + http_response_code(401); + echo json_encode(['error' => 'Authentication required.']); + exit; + } + // For regular pages, redirect to login + header('Location: /login.php'); + exit; +} + +// --- Role-based Access Control (RBAC) Helper Functions --- + +/** + * Checks if the current logged-in user is an admin. + * @return bool + */ +function isAdmin() { + return isset($_SESSION['role']) && $_SESSION['role'] === 'admin'; +} + +/** + * Checks if the current logged-in user is a standard user. + * @return bool + */ +function isUser() { + return isset($_SESSION['role']) && $_SESSION['role'] === 'user'; +} + +/** + * Checks if the current logged-in user is a viewer. + * @return bool + */ +function isViewer() { + return isset($_SESSION['role']) && $_SESSION['role'] === 'viewer'; +} + +/** + * Checks if the current user can edit a specific tab. + * Admins can edit any tab. + * Users can only edit tabs they own. + * @param int $tab_id The ID of the tab to check. + * @return bool + */ +function canEditTab($tab_id) { + if (isAdmin()) { + return true; + } + + if (isUser()) { + global $pdo; + $stmt = $pdo->prepare("SELECT id FROM tabs WHERE id = ? AND user_id = ?"); + $stmt->execute([$tab_id, $_SESSION['user_id']]); + if ($stmt->fetch()) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/include/css/style.css b/include/css/style.css new file mode 100644 index 0000000..efe0a64 --- /dev/null +++ b/include/css/style.css @@ -0,0 +1,189 @@ +/* Custom CSS for Bookmarkly */ + +html { + font-size: 14px; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + /* Background properties will be set dynamically via settings */ +} + +@media (min-width: 1280px) { + .container { + max-width: 1400px; + } +} + +body[style*="background-image"] { + background-color: transparent !important; +} + +.h5 { + font-weight: 600 !important; + +} + +.bookmark-link { + background-color: transparent !important; + border: none !important; + padding: 0.5rem 1rem 0.5rem 0 !important; + color: inherit !important; + font-weight: 500 !important; + transition: transform 0.2s ease-in-out; +} + +.bookmark-link:hover { + transform: translateY(-3px); +} + +.bookmark-link > div { + min-width: 0; +} + +.bookmark-link span { + display: block; +} + +.bookmark-link span, +.bookmark-link small { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bookmark-icon { + width: 14px; + height: 14px; + object-fit: contain; +} + +.bookmark-favorite-icon { + width: 48px; + height: 48px; + object-fit: contain; +} + +.category-column { + background-color: rgba(var(--bs-body-bg-rgb), var(--theme-transparency, 0.85)); + backdrop-filter: blur(10px); + padding: 15px; + border-radius: 8px; + height: 100%; +} + +.list-group-item { + background-color: transparent !important; + border: none !important; + padding: 0.5rem 0 !important; + color: inherit !important; + text-align: left; + transition: transform 0.2s ease-in-out; + font-weight: 400 !important; + font-size: 0.9rem !important; +} + +.list-group-item-action:hover { + background-color: transparent !important; + transform: translateX(5px); +} + +.list-group-item span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.navbar-brand { + display: flex; + align-items: center; + gap: 0.5rem; /* Space between logo and title */ + color: #f8f9fa !important; + font-size: 1.5rem; /* Increased font size for site title */ +} + +/* Class to force light text color when background is dark */ +body.dark-background-active { + color: #f8f9fa !important; +} + +body.dark-background-active h1, +body.dark-background-active h2, +body.dark-background-active h3, +body.dark-background-active h4, +body.dark-background-active h5, +body.dark-background-active .card, +body.dark-background-active .nav-link, +body.dark-background-active label { + color: #fff !important; +} + +body.dark-background-active .form-range::-webkit-slider-thumb { + background: #fff; +} + +body.dark-background-active .form-range::-moz-range-thumb { + background: #fff; +} + +body.dark-background-active .text-muted { + color: rgba(255, 255, 255, 0.6) !important; + font-weight: 100 !important; +} + +.sort-handle { + cursor: move; +} + +/* Make links more readable on dark backgrounds */ +body.dark-background-active .bookmark-list a { + color: var(--bs-light); +} + +body.dark-background-active .bookmark-list a:hover { + color: var(--bs-gray-400); +} + +[data-bs-theme="dark"] .form-control { + background-color: var(--bs-dark); + color: var(--bs-light); + border-color: var(--bs-gray-700); +} + +[data-bs-theme="dark"] .form-control:focus { + background-color: var(--bs-dark); + color: var(--bs-light); + border-color: var(--bs-primary); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25); +} + +.bookmark-list a { + text-decoration: none; +} + +.bookmark-list a:hover { + text-decoration: underline; +} + +#v-pills-tab .nav-link { + text-align: left; +} + +body.dark-background-active .form-text, +[data-bs-theme="dark"] .form-text { + color: rgba(255, 255, 255, 0.55); +} + +[data-bs-theme="dark"] .list-group-item { + background-color: var(--bs-gray-800); + border-color: var(--bs-gray-700); +} + +/* Custom styles for transparent theme */ +[data-bs-theme="transparent"] { + +} diff --git a/include/footer.php b/include/footer.php new file mode 100644 index 0000000..4b92ae4 --- /dev/null +++ b/include/footer.php @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/include/header.php b/include/header.php new file mode 100644 index 0000000..561d38e --- /dev/null +++ b/include/header.php @@ -0,0 +1,150 @@ + + + + + + + <?php echo htmlspecialchars($settings['site_title'] ?? 'Bookmarkly'); ?> + + + + + + + + + +
+ diff --git a/include/images/default-icon.png b/include/images/default-icon.png new file mode 100644 index 0000000..a493e4f Binary files /dev/null and b/include/images/default-icon.png differ diff --git a/include/images/favicon.ico b/include/images/favicon.ico new file mode 100644 index 0000000..d226b26 Binary files /dev/null and b/include/images/favicon.ico differ diff --git a/include/init.php b/include/init.php new file mode 100644 index 0000000..91194b3 --- /dev/null +++ b/include/init.php @@ -0,0 +1,229 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +} catch (PDOException $e) { + die("Database connection failed: " . $e->getMessage()); +} + +// Load language files, which now uses the $pdo object from this script +require_once __DIR__ . '/language.php'; + +// Start session is now in language.php +// session_start(); + +// Error reporting for development is now handled in language.php based on settings +/* +ini_set('display_errors', 1); +ini_set('display_startup_errors', 1); +error_reporting(E_ALL); +*/ + +// Function to check if a table exists +function tableExists($pdo, $tableName) { + try { + $result = $pdo->query("SELECT 1 FROM {$tableName} LIMIT 1"); + } catch (Exception $e) { + return false; + } + return $result !== false; +} + +// Function to check if a column exists in a table +function columnExists($pdo, $tableName, $columnName) { + try { + $result = $pdo->query("SELECT {$columnName} FROM {$tableName} LIMIT 1"); + } catch (Exception $e) { + return false; + } + return $result !== false; +} + +// Initialize database schema if tables don't exist +$db_initialized = tableExists($pdo, 'users'); + +if ($db_initialized) { + // For existing installations, run migration checks + if (!columnExists($pdo, 'users', 'role')) { + $pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); + $pdo->exec("UPDATE users SET role = 'admin' WHERE id = 1"); + } +} + +if (!$db_initialized) { + // Create users table + $pdo->exec(" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' + ) + "); + // Insert a default user (e.g., admin/password) + $default_password = password_hash('password', PASSWORD_DEFAULT); + $stmt = $pdo->prepare("INSERT INTO users (username, password, role) VALUES (?, ?, ?)"); + $stmt->execute(['admin', $default_password, 'admin']); + + // Create settings table + $pdo->exec(" + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + "); + // Insert default settings + $default_settings = [ + 'dashboard_protected' => '0', + 'remember_me_duration' => '2 weeks', + 'open_new_tab' => '1', + 'language' => 'en', + 'favicon_extractor_url' => 'https://favicon.hibbit.cloud', + 'debug_mode' => '0', + 'theme' => 'transparent', + 'background_image_url' => '', + 'background_brightness' => '50', + 'background_saturation' => '100', + 'pexels_api_key' => 'VaNzz6B9q31971WDf1h4TjwEvgib6XvXQp58AhJnX7dGqDawavi7SQAd', + 'custom_css' => '', + 'theme_transparency' => '40' + ]; + $stmt = $pdo->prepare("INSERT INTO settings (key, value) VALUES (?, ?)"); + foreach ($default_settings as $key => $value) { + $stmt->execute([$key, $value]); + } + + // Create categories table + $pdo->exec(" + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sort_order INTEGER + ) + "); + + // Create bookmarks table + $pdo->exec(" + CREATE TABLE bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER, + title TEXT NOT NULL, + url TEXT NOT NULL, + icon TEXT, + is_favorite INTEGER DEFAULT 0, + sort_order INTEGER, + FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE + ) + "); +} + +// Check if tabs table exists +if (!tableExists($pdo, 'tabs')) { + $pdo->exec(" + CREATE TABLE tabs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + user_id INTEGER, + sort_order INTEGER, + background_image_url TEXT, + background_brightness INTEGER DEFAULT 100, + background_saturation INTEGER DEFAULT 100, + theme TEXT DEFAULT 'dark', + theme_transparency INTEGER DEFAULT 85, + login_token VARCHAR(255) UNIQUE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL + ) + "); + // Insert a default tab + $default_token = bin2hex(random_bytes(16)); + $pdo->prepare("INSERT INTO tabs (name, sort_order, login_token) VALUES (?, ?, ?)") + ->execute(['Default', 1, $default_token]); +} + +// Migration: Add sort_order to tabs table if it doesn't exist +if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'sort_order')) { + $pdo->exec("ALTER TABLE tabs ADD COLUMN sort_order INTEGER"); + // Initialize sort order based on existing IDs + $pdo->exec("UPDATE tabs SET sort_order = id"); +} + +// Migration: Add new columns to tabs table if they don't exist +if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'background_image_url')) { + $pdo->exec("ALTER TABLE tabs ADD COLUMN background_image_url TEXT"); +} +if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'background_brightness')) { + $pdo->exec("ALTER TABLE tabs ADD COLUMN background_brightness INTEGER DEFAULT 100"); +} +if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'background_saturation')) { + $pdo->exec("ALTER TABLE tabs ADD COLUMN background_saturation INTEGER DEFAULT 100"); +} +if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'theme')) { + $pdo->exec("ALTER TABLE tabs ADD COLUMN theme TEXT DEFAULT 'dark'"); +} +if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'theme_transparency')) { + $pdo->exec("ALTER TABLE tabs ADD COLUMN theme_transparency INTEGER DEFAULT 85"); +} + +// Migration: Add column_count to tabs table if it doesn't exist +if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'column_count')) { + $pdo->exec("ALTER TABLE tabs ADD COLUMN column_count INTEGER DEFAULT 5"); +} + +// Check if tab_id column exists in bookmarks table +if (!columnExists($pdo, 'bookmarks', 'tab_id')) { + $pdo->exec("ALTER TABLE bookmarks ADD COLUMN tab_id INTEGER"); + // Set default tab_id for existing bookmarks + $pdo->exec("UPDATE bookmarks SET tab_id = 1"); +} + +// Check if tab_id column exists in categories table +if (!columnExists($pdo, 'categories', 'tab_id')) { + $pdo->exec("ALTER TABLE categories ADD COLUMN tab_id INTEGER"); + // Set default tab_id for existing categories + $pdo->exec("UPDATE categories SET tab_id = 1"); +} + +// Create tab_viewer_access table +if (!tableExists($pdo, 'tab_viewer_access')) { + $pdo->exec(" + CREATE TABLE tab_viewer_access ( + user_id INTEGER NOT NULL, + tab_id INTEGER NOT NULL, + PRIMARY KEY (user_id, tab_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (tab_id) REFERENCES tabs (id) ON DELETE CASCADE + ) + "); +} + +// Create user_tab_visibility table +if (!tableExists($pdo, 'user_tab_visibility')) { + $pdo->exec(" + CREATE TABLE user_tab_visibility ( + user_id INTEGER NOT NULL, + tab_id INTEGER NOT NULL, + PRIMARY KEY (user_id, tab_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (tab_id) REFERENCES tabs (id) ON DELETE CASCADE + ) + "); +} + +// Load all settings into a global variable for easy access +try { + $settings_stmt = $pdo->query("SELECT key, value FROM settings"); + $settings = $settings_stmt->fetchAll(PDO::FETCH_KEY_PAIR); + $GLOBALS['settings'] = $settings; // Make it globally available +} catch (PDOException $e) { + // If settings can't be loaded, initialize as empty array to prevent fatal errors + $settings = []; + $GLOBALS['settings'] = []; +} + +// Set timezone, default to UTC +date_default_timezone_set($settings['timezone'] ?? 'UTC'); diff --git a/include/js/script.js b/include/js/script.js new file mode 100644 index 0000000..a9228fd --- /dev/null +++ b/include/js/script.js @@ -0,0 +1,323 @@ +// JavaScript for Bookmarkly + +document.addEventListener('DOMContentLoaded', function() { + // Handle Edit Category Modal + const editCategoryModal = document.getElementById('editCategoryModal'); + if (editCategoryModal) { + editCategoryModal.addEventListener('show.bs.modal', function (event) { + const button = event.relatedTarget; + const id = button.getAttribute('data-bs-id'); + const name = button.getAttribute('data-bs-name'); + + const idInput = editCategoryModal.querySelector('#edit-category-id'); + const nameInput = editCategoryModal.querySelector('#edit-category-name'); + + idInput.value = id; + nameInput.value = name; + }); + } + + // --- Bookmark Modals --- + const editBookmarkModal = document.getElementById('editBookmarkModal'); + if (editBookmarkModal) { + editBookmarkModal.addEventListener('show.bs.modal', function (event) { + const button = event.relatedTarget; + const id = button.getAttribute('data-bs-id'); + const title = button.getAttribute('data-bs-title'); + const url = button.getAttribute('data-bs-url'); + const category = button.getAttribute('data-bs-category'); + const icon = button.getAttribute('data-bs-icon'); + const iconSrc = button.getAttribute('data-bs-icon-src'); + const favorite = button.getAttribute('data-bs-favorite'); + + const modalTitle = editBookmarkModal.querySelector('.modal-title'); + const idInput = editBookmarkModal.querySelector('#edit-bookmark-id'); + const titleInput = editBookmarkModal.querySelector('#edit-bookmark-title'); + const urlInput = editBookmarkModal.querySelector('#edit-bookmark-url'); + const categoryInput = editBookmarkModal.querySelector('#edit-bookmark-category'); + const iconUrlInput = editBookmarkModal.querySelector('#edit-bookmark-icon-url'); + const favoriteInput = editBookmarkModal.querySelector('#edit-bookmark-favorite'); + + const currentIconPreview = document.getElementById('current-icon-preview'); + const currentIconContainer = document.getElementById('current-icon-container'); + const removeCustomIconBtn = document.getElementById('remove-custom-icon'); + const removeIconInput = document.getElementById('remove-icon-input'); + + modalTitle.textContent = 'Edit ' + title; + idInput.value = id; + titleInput.value = title; + urlInput.value = url; + categoryInput.value = category; + favoriteInput.checked = (favorite == '1'); + + // Handle icon display logic + currentIconContainer.style.display = 'none'; // Hide by default + + // Handle text input for icon URL/domain + if (icon && icon.startsWith('data/images/custom/')) { + iconUrlInput.value = ''; // Clear if it's a custom icon + } else { + // For domains or full URLs, just show the value in the input. + iconUrlInput.value = icon; + } + + // Handle current icon preview and remove button + if (icon && icon.startsWith('data/images/custom/')) { + currentIconPreview.src = iconSrc; + currentIconContainer.style.display = 'block'; + removeIconInput.value = '0'; + } else { + currentIconContainer.style.display = 'none'; + } + + removeCustomIconBtn.onclick = function() { + removeIconInput.value = '1'; + currentIconContainer.style.display = 'none'; + document.getElementById('edit-bookmark-icon-upload').value = ''; + }; + }); + + // Reset remove icon flag when modal is hidden + editBookmarkModal.addEventListener('hidden.bs.modal', function () { + document.getElementById('remove-icon-input').value = '0'; + document.getElementById('edit-bookmark-icon-upload').value = ''; // Clear file input + }); + } + + // --- Favicon Fetching --- + function fetchFavicon(url, targetInputId) { + if (!url) return; + try { + // We only want to store the domain, not the full generated URL + const domain = new URL(url).hostname; + const targetInput = document.getElementById(targetInputId); + if (targetInput) { + targetInput.value = domain; + } + } catch (e) { + console.error('Invalid URL for favicon fetching:', e); + const targetInput = document.getElementById(targetInputId); + if (targetInput) { + targetInput.value = ''; + } + } + } + + // Event listener for fetching favicon in ADD modal + const fetchButtonAdd = document.getElementById('fetch-favicon-add'); + const urlInputAdd = document.getElementById('bookmark-url'); + if (fetchButtonAdd && urlInputAdd) { + // Auto-fetch on change + urlInputAdd.addEventListener('change', function() { + fetchFavicon(urlInputAdd.value, 'bookmark-icon-url'); + }); + // Manual fetch on button click + fetchButtonAdd.addEventListener('click', function() { + fetchFavicon(urlInputAdd.value, 'bookmark-icon-url'); + }); + } + + // Event listener for fetching favicon in EDIT modal + const fetchButtonEdit = document.getElementById('fetch-favicon-edit'); + const urlInputEdit = document.getElementById('edit-bookmark-url'); + if (fetchButtonEdit && urlInputEdit) { + urlInputEdit.addEventListener('change', function() { + const iconContainer = document.getElementById('current-icon-container'); + // Only fetch favicon if there is no custom icon currently set + if (iconContainer && iconContainer.style.display === 'none') { + fetchFavicon(urlInputEdit.value, 'edit-bookmark-icon-url'); + } + }); + fetchButtonEdit.addEventListener('click', function() { + fetchFavicon(urlInputEdit.value, 'edit-bookmark-icon-url'); + }); + } + + + // --- Pexels API --- + const pexelsSearchButton = document.getElementById('pexels_search_button'); + const pexelsSearchQuery = document.getElementById('pexels_search_query'); + const pexelsResultsContainer = document.getElementById('pexels_results'); + const bgUrlInput = document.getElementById('background_image_url'); + + if (pexelsSearchButton && pexelsSearchQuery && pexelsResultsContainer && bgUrlInput) { + const performSearch = () => { + const query = pexelsSearchQuery.value; + if (!query) { + alert('Please enter a search term.'); + return; + } + + pexelsResultsContainer.innerHTML = '
Loading...
'; + + fetch(`api/pexels.php?query=${encodeURIComponent(query)}`, { + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + pexelsResultsContainer.innerHTML = ''; + if (data.error) { + pexelsResultsContainer.innerHTML = `
${data.error}
`; + return; + } + if (data.photos && data.photos.length > 0) { + data.photos.forEach(photo => { + const imgContainer = document.createElement('div'); + imgContainer.className = 'col'; + const img = document.createElement('img'); + img.src = photo.src.medium; + img.className = 'img-thumbnail'; + img.style.cursor = 'pointer'; + img.alt = photo.alt; + img.addEventListener('click', () => { + bgUrlInput.value = photo.src.original; + // Optionally, show a preview + document.body.style.backgroundImage = `url(${photo.src.original})`; + pexelsResultsContainer.innerHTML = '
Background selected! Save theme settings to apply.
'; + }); + imgContainer.appendChild(img); + pexelsResultsContainer.appendChild(imgContainer); + }); + } else { + pexelsResultsContainer.innerHTML = '
No images found for your query.
'; + } + }) + .catch(error => { + console.error('Error fetching from Pexels API:', error); + pexelsResultsContainer.innerHTML = '
An error occurred while searching.
'; + }); + }; + + pexelsSearchButton.addEventListener('click', performSearch); + pexelsSearchQuery.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + performSearch(); + } + }); + } + + // Live preview for background settings + const brightnessSlider = document.getElementById('background_brightness'); + const saturationSlider = document.getElementById('background_saturation'); + const transparencySlider = document.getElementById('theme_transparency'); + const themeSelector = document.getElementById('theme'); + const transparencyContainer = document.getElementById('transparency-slider-container'); + + const updateTextColor = () => { + if (themeSelector && themeSelector.value !== 'dark' && brightnessSlider && brightnessSlider.value < 70) { + document.body.classList.add('dark-background-active'); + } else { + document.body.classList.remove('dark-background-active'); + } + }; + + const toggleTransparencySlider = () => { + if (themeSelector && transparencyContainer) { + transparencyContainer.style.display = themeSelector.value === 'transparent' ? 'block' : 'none'; + } + }; + + // Initial check + toggleTransparencySlider(); + + if(themeSelector) { + themeSelector.addEventListener('change', () => { + toggleTransparencySlider(); + updateTextColor(); + }); + } + + if (brightnessSlider) { + brightnessSlider.addEventListener('input', (event) => { + document.documentElement.style.setProperty('--bg-brightness', `${event.target.value}%`); + updateTextColor(); + }); + } + + if (saturationSlider) { + saturationSlider.addEventListener('input', (event) => { + document.documentElement.style.setProperty('--bg-saturation', `${event.target.value}%`); + }); + } + + if (transparencySlider) { + transparencySlider.addEventListener('input', (event) => { + document.documentElement.style.setProperty('--theme-transparency', event.target.value / 100); + }); + } + + // Initialize SortableJS for drag-and-drop + initSortable(); +}); + +function initSortable() { + const categoryList = document.getElementById('category-list'); + if (categoryList) { + new Sortable(categoryList, { + handle: '.sort-handle', + animation: 150, + onEnd: function (evt) { + const order = Array.from(evt.target.children).map(el => el.getAttribute('data-id')); + updateSortOrder('category', order); + } + }); + } + + const bookmarkLists = document.querySelectorAll('.bookmark-list'); + bookmarkLists.forEach(list => { + new Sortable(list, { + handle: '.sort-handle', + animation: 150, + onEnd: function (evt) { + const order = Array.from(evt.target.children).map(el => el.getAttribute('data-id')); + updateSortOrder('bookmark', order); + } + }); + }); +} + +function updateSortOrder(type, order) { + fetch('api/update_order.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ type: type, order: order }) + }) + .then(response => response.json()) + .then(data => { + if (!data.success) { + console.error('Failed to update sort order:', data.error); + // Optionally, show an error message to the user + } + }) + .catch(error => console.error('Error updating sort order:', error)); +} + +function optimizeImage(path) { + if (confirm('Are you sure you want to optimize this image? The original file will be overwritten.')) { + const formData = new FormData(); + formData.append('path', path); + + fetch('api/optimize_image.php', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Image optimized successfully. The page will now reload.'); + window.location.reload(); + } else { + alert('An error occurred: ' + data.message); + } + }) + .catch(error => { + console.error('Error:', error); + alert('A network error occurred. Please try again.'); + }); + } +} \ No newline at end of file diff --git a/include/language.php b/include/language.php new file mode 100644 index 0000000..90289dd --- /dev/null +++ b/include/language.php @@ -0,0 +1,49 @@ +query("SELECT value FROM settings WHERE key = 'language'"); + $language = $stmt->fetchColumn(); + + // Also fetch debug mode setting here + $stmt = $pdo->query("SELECT value FROM settings WHERE key = 'debug_mode'"); + $debug_mode = $stmt->fetchColumn(); + +} catch (Exception $e) { + // If database/table doesn't exist yet, default to English and debug off + $language = 'en'; + $debug_mode = '0'; +} +$language = $language ?: 'en'; +$debug_mode = $debug_mode ?: '0'; + + +// Set error reporting based on debug mode +if ($debug_mode === '1') { + ini_set('display_errors', 1); + ini_set('display_startup_errors', 1); + error_reporting(E_ALL); +} else { + ini_set('display_errors', 0); + ini_set('display_startup_errors', 0); + error_reporting(0); +} + +// Load the language file +$lang_file = __DIR__ . "/../lang/{$language}.php"; +if (file_exists($lang_file)) { + $translations = require $lang_file; +} else { + // Fallback to English if the language file doesn't exist + $translations = require __DIR__ . '/../lang/en.php'; +} + +// Function to get a translated string +function translate($key) { + global $translations; + return $translations[$key] ?? $key; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..7d2ec1d --- /dev/null +++ b/index.php @@ -0,0 +1,270 @@ +prepare("SELECT id FROM tabs WHERE login_token = ?"); + $stmt->execute([$token]); + $tab = $stmt->fetch(); + + if ($tab) { + // Log in as a temporary guest for this tab + $_SESSION['guest_tab_id'] = $tab['id']; + + // Redirect to the tab view without the token in the URL + header('Location: index.php?tab=' . $tab['id']); + exit; + } else { + // Invalid token, redirect to login or show an error + header('Location: login.php?error=invalid_token'); + exit; + } +} + +require_once 'include/auth.php'; + +// Fetch all settings for theme and layout +$stmt = $pdo->query("SELECT key, value FROM settings"); +$settings = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + +$open_in_new_tab = $settings['open_new_tab'] ?? '1'; +$target_attr = $open_in_new_tab === '1' ? 'target="_blank"' : ''; + +// Get current user ID if logged in +$current_user_id = $_SESSION['user_id'] ?? null; +$current_user_role = $_SESSION['role'] ?? null; +$guest_tab_id = $_SESSION['guest_tab_id'] ?? null; + +// Base query for fetching tabs +$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 ($guest_tab_id) { + // Guest user with token can only see the specific tab + $tabs_stmt_sql .= "WHERE t.id = :guest_tab_id"; + $params[':guest_tab_id'] = $guest_tab_id; +} else 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 (ownerless) tabs + $tabs_stmt_sql .= "WHERE t.user_id IS NULL"; +} + +$tabs_stmt_sql .= " ORDER BY t.sort_order, t.id"; + +$tabs_stmt = $pdo->prepare($tabs_stmt_sql); +$tabs_stmt->execute($params); +$tabs = $tabs_stmt->fetchAll(); + +// Determine the default tab to show. +// If the requested tab is not in the visible list, fall back to the first visible one. +$visible_tab_ids = array_column($tabs, 'id'); +$current_tab_id = isset($_GET['tab']) ? (int)$_GET['tab'] : ($visible_tab_ids[0] ?? 1); +if (!in_array($current_tab_id, $visible_tab_ids)) { + $current_tab_id = $visible_tab_ids[0] ?? 1; +} + +// Fetch details for the current tab to get background info +$current_tab_data = null; +if ($current_tab_id) { + $tab_details_stmt = $pdo->prepare("SELECT * FROM tabs WHERE id = :id"); + $tab_details_stmt->execute([':id' => $current_tab_id]); + $current_tab_data = $tab_details_stmt->fetch(); +} + +// Get the ID of the 'FAVORITES' category (case-insensitive) +$fav_cat_stmt = $pdo->prepare("SELECT id FROM categories WHERE UPPER(name) = 'FAVORITES'"); +$fav_cat_stmt->execute(); +$favorites_cat_id = $fav_cat_stmt->fetchColumn(); + +// Fetch favorite bookmarks from the 'FAVORITES' category for the current tab +$fav_stmt = $pdo->prepare(" + SELECT b.* + FROM bookmarks b + JOIN categories c ON b.category_id = c.id + WHERE b.is_favorite = 1 AND c.tab_id = :tab_id + ORDER BY b.sort_order ASC, b.title ASC +"); +$fav_stmt->execute([':tab_id' => $current_tab_id]); +$favorites = $fav_stmt->fetchAll(); + +// Fetch categories for the current tab, excluding 'FAVORITES' +$cat_stmt = $pdo->prepare("SELECT * FROM categories WHERE tab_id = :tab_id AND UPPER(name) != 'FAVORITES' ORDER BY sort_order ASC, name ASC"); +$cat_stmt->execute([':tab_id' => $current_tab_id]); +$categories = $cat_stmt->fetchAll(); + +// Fetch bookmarks for each category +$bookmarks_by_cat = []; +if ($categories) { + $bookmarks_stmt = $pdo->prepare("SELECT * FROM bookmarks WHERE category_id = :cat_id AND (is_favorite IS NULL OR is_favorite = 0) ORDER BY sort_order ASC, title ASC"); + foreach ($categories as $category) { + $bookmarks_stmt->execute([':cat_id' => $category['id']]); + $bookmarks_by_cat[$category['id']] = $bookmarks_stmt->fetchAll(); + } +} + +require_once 'include/header.php'; +?> + +
+ 1): ?> + + + + +
+ + + + diff --git a/lang/en.php b/lang/en.php new file mode 100644 index 0000000..74ced2f --- /dev/null +++ b/lang/en.php @@ -0,0 +1,148 @@ + 'Bookmarkly', + 'header_settings' => 'Settings', + 'header_manage_bookmarks' => 'Manage Bookmarks', + 'header_logout' => 'Logout', + + // Nav specific translations + 'nav_settings' => 'Settings', + 'nav_manage' => 'Bookmarks', + 'nav_manage_tabs' => 'Manage Tabs', + 'nav_logout' => 'Logout', + + // Login page + 'login_title' => 'Login', + 'username' => 'Username', + 'password' => 'Password', + 'login_button' => 'Login', + 'error_invalid_credentials' => 'Invalid username or password.', + 'error_fill_fields' => 'Please fill in both fields.', + + 'index_search_placeholder' => 'Search (Ctrl+K)', + 'index_favorites' => 'FAVORITES', + 'index_welcome_title' => 'Welcome to Bookmarkly!', + 'index_welcome_text' => 'You don\'t have any bookmarks yet.

Login: admin / password (change it after login) ↗️

', + 'index_welcome_button' => '👉 Add your first bookmark', + + 'bookmarks_manage_title' => 'Manage Bookmarks', + 'bookmarks_add_bookmark_button' => 'Add Bookmark', + 'bookmarks_categories' => 'Categories', + 'bookmarks_new_category_placeholder' => 'New category name', + 'bookmarks_add_category_button' => 'Add', + 'bookmarks_bookmarks' => 'Bookmarks', + 'bookmarks_category_empty' => 'No bookmarks in this category yet.', + 'bookmarks_no_category_text' => 'You need to create a category first before you can add bookmarks.', + 'bookmarks_delete_category_confirm' => 'Are you sure you want to delete this category and all its bookmarks?', + 'bookmarks_delete_bookmark_confirm' => 'Are you sure you want to delete this bookmark?', + + 'modal_add_bookmark_title' => 'Add New Bookmark', + 'modal_edit_bookmark_title' => 'Edit Bookmark', + 'modal_edit_category_title' => 'Edit Category', + 'modal_category_name' => 'Category Name', + 'modal_title' => 'Title', + 'modal_url' => 'URL', + 'modal_category' => 'Category', + 'modal_select_category' => 'Select a category...', + 'modal_icon_url' => 'Icon URL (optional)', + 'modal_icon_url_help' => 'Find icons at selfh.st/icons and dashboardicons.com/icons.', + 'modal_icon_upload' => 'Upload Icon (optional)', + 'modal_current_icon' => 'Current Icon', + 'modal_remove_icon_button' => 'Remove Custom Icon', + 'modal_fetch_favicon_button' => 'Fetch Favicon from URL', + 'modal_mark_as_favorite' => 'Mark as Favorite', + 'modal_close_button' => 'Close', + 'modal_save_bookmark_button' => 'Save Bookmark', + 'modal_save_changes_button' => 'Save Changes', + + 'settings_title' => 'Settings', + 'settings_general_tab' => 'General', + 'settings_theme_tab' => 'Theme & Background', + 'settings_backup_tab' => 'Backup & Restore', + 'settings_save_success' => 'Settings saved successfully.', + 'settings_general_title' => 'General Settings', + 'settings_open_new_tab' => 'Open bookmarks in a new tab', + 'settings_dashboard_protection' => 'Protect dashboard with password (requires re-login)', + 'settings_stay_logged_in' => '"Stay logged in" duration', + 'settings_language' => 'Language', + 'settings_favicon_extractor_url' => 'Favicon Extractor Base URL', + 'settings_favicon_extractor_url_help' => 'Provide the service base URL (e.g., http://localhost:3000). The path /favicon/{URL}?larger=true will be appended automatically.', + 'settings_debug_mode' => 'Enable Debug mode', + 'settings_save_settings_button' => 'Save Settings', + 'settings_user_account_title' => 'User Account', + 'settings_new_password' => 'New Password (leave blank to keep current)', + 'settings_confirm_password' => 'Confirm New Password', + 'settings_update_profile_button' => 'Update Profile', + 'settings_user_update_success' => 'User profile updated.', + 'settings_password_mismatch' => 'New passwords do not match.', + + 'settings_theme_title' => 'Theme & Background', + 'settings_theme' => 'Theme', + 'settings_theme_light' => 'Light', + 'settings_theme_dark' => 'Dark', + 'settings_theme_transparent' => 'Transparent', + 'settings_theme_transparency' => 'Theme Transparency', + 'settings_background_url' => 'Background Image URL', + 'settings_background_upload' => 'Upload Background', + 'settings_background_current' => 'Current background:', + 'settings_background_optimize' => 'Optimize', + 'settings_background_brightness' => 'Background Brightness', + 'settings_background_saturation' => 'Background Saturation', + 'settings_pexels_integration' => 'Pexels Integration', + 'settings_pexels_api_key' => 'Pexels API Key', + 'settings_pexels_search_placeholder' => 'Search for a background on Pexels...', + 'settings_pexels_search_button' => 'Search', + 'settings_custom_css' => 'Custom CSS', + 'settings_custom_footer' => 'Custom Footer HTML', + 'settings_save_theme_button' => 'Save Theme Settings', + + 'settings_backup_title' => 'Backup & Restore', + 'settings_backup_text' => 'Create a backup of your data, including the database and custom images. You can restore your instance from a backup file.', + 'settings_backup_create_button' => 'Create Backup', + 'settings_backup_restore_title' => 'Restore from Backup', + 'settings_backup_restore_text' => 'Upload a backup file to restore your Bookmarkly instance. This will overwrite current data.', + 'settings_backup_restore_input' => 'Backup File (.zip)', + 'settings_backup_restore_button' => 'Restore Backup', + 'settings_backup_restore_success' => 'Backup restored successfully. Please log in again.', + 'settings_backup_restore_failed' => 'Failed to restore backup. Please ensure it is a valid Bookmarkly backup file.', + + 'settings_backup_create_title' => 'Create Backup', + 'settings_backup_full' => 'Entire Site (Full Backup)', + 'settings_backup_tab_prefix' => 'Tab', + + // System Maintenance + 'settings_maintenance_fix_orphaned_bookmarks_title' => 'Fix Orphaned Bookmarks', + 'settings_maintenance_fix_orphaned_bookmarks_desc' => 'Finds any bookmarks that are not assigned to a tab and assigns them to the default public tab.', + 'settings_maintenance_fix_orphaned_bookmarks_button' => 'Fix Orphaned Bookmarks', + + // New strings for import feature + 'settings_import_title' => 'Import Bookmarks into a Specific Tab', + 'settings_import_text' => 'Import bookmarks from a Bookmarkly backup file into a tab of your choice. This will add the bookmarks and categories from the backup to the selected tab without overwriting existing data.', + 'settings_import_input' => 'Backup File (.zip)', + 'settings_import_select_tab' => 'Target Tab', + 'settings_import_select_tab_placeholder' => '-- Select a Tab --', + 'settings_import_button' => 'Import to Tab', + + // User Management + 'user_management_title' => 'User Management', + 'user_management_username' => 'Username', + 'user_settings_role' => 'Role', + 'user_settings_roles_title' => 'Role Permissions', + 'user_settings_role_admin' => 'Admin', + 'user_settings_role_admin_desc' => 'Can manage users, tabs, settings, and all bookmarks/categories.', + 'user_settings_role_user' => 'User', + 'user_settings_role_user_desc' => 'Can manage bookmarks and categories on tabs they own. Can be granted read-only access to other tabs.', + 'user_settings_role_viewer' => 'Viewer', + 'user_settings_role_viewer_desc' => 'Can only view public tabs and specific tabs they have been granted access to. Cannot edit anything.', + + // Tabs + 'tab_default_name' => 'Public', + 'tab_unique_url_label' => 'Unique Login URL', + 'tab_generate_token_button' => 'Generate Token', + 'tab_generate_token_confirm' => 'Are you sure you want to generate a new login token? Any existing token will be invalidated.', + 'tab_clear_token_button' => 'Clear Token', + 'tab_clear_token_confirm' => 'Are you sure you want to clear the login token? The unique URL will no longer work.', + 'tooltip_copy_to_clipboard' => 'Copy to clipboard', + 'tooltip_copied' => 'Copied!', +]; diff --git a/lang/nl.php b/lang/nl.php new file mode 100644 index 0000000..ce2d3b5 --- /dev/null +++ b/lang/nl.php @@ -0,0 +1,149 @@ + 'Bookmarkly', + 'header_settings' => 'Instellingen', + 'header_manage_bookmarks' => 'Beheer Bladwijzers', + 'header_logout' => 'Uitloggen', + + // Nav specific translations + 'nav_settings' => 'Instellingen', + 'nav_manage' => 'Bladwijzers', + 'nav_manage_tabs' => 'Beheer Tabs', + 'nav_logout' => 'Uitloggen', + + // Login page + 'login_title' => 'Inloggen', + 'username' => 'Gebruikersnaam', + 'password' => 'Wachtwoord', + 'login_button' => 'Inloggen', + 'error_invalid_credentials' => 'Ongeldige gebruikersnaam of wachtwoord.', + 'error_fill_fields' => 'Vul alstublieft beide velden in.', + + 'index_search_placeholder' => 'Zoeken (Ctrl+K)', + 'index_favorites' => 'Favorieten', + 'index_welcome_title' => 'Welkom bij Bookmarkly!

Login: admin / password (wijzig login na inloggen!) ↗️
', + 'index_welcome_text' => 'Je hebt nog geen bladwijzers.', + 'index_welcome_button' => '👉 Voeg je eerste bladwijzer toe', + + 'bookmarks_manage_title' => 'Bladwijzers Beheren', + 'bookmarks_add_bookmark_button' => 'Bladwijzer Toevoegen', + 'bookmarks_categories' => 'Categorieën', + 'bookmarks_new_category_placeholder' => 'Nieuwe categorienaam', + 'bookmarks_add_category_button' => 'Toevoegen', + 'bookmarks_bookmarks' => 'Bladwijzers', + 'bookmarks_category_empty' => 'Nog geen bladwijzers in deze categorie.', + 'bookmarks_no_category_text' => 'Je moet eerst een categorie aanmaken voordat je bladwijzers kunt toevoegen.', + 'bookmarks_delete_category_confirm' => 'Weet je zeker dat je deze categorie en alle bijbehorende bladwijzers wilt verwijderen?', + 'bookmarks_delete_bookmark_confirm' => 'Weet je zeker dat je deze bladwijzer wilt verwijderen?', + + 'modal_add_bookmark_title' => 'Nieuwe Bladwijzer Toevoegen', + 'modal_edit_bookmark_title' => 'Bladwijzer Bewerken', + 'modal_edit_category_title' => 'Categorie Bewerken', + 'modal_category_name' => 'Categorienaam', + 'modal_title' => 'Titel', + 'modal_url' => 'URL', + 'modal_category' => 'Categorie', + 'modal_select_category' => 'Selecteer een categorie...', + 'modal_icon_url' => 'Icoon URL (optioneel)', + 'modal_icon_url_help' => 'Vind iconen op selfh.st/icons en dashboardicons.com/icons.', + 'modal_icon_upload' => 'Icoon Uploaden (optioneel)', + 'modal_current_icon' => 'Huidig Icoon', + 'modal_remove_icon_button' => 'Verwijder Aangepast Icoon', + 'modal_fetch_favicon_button' => 'Favicon ophalen van URL', + 'modal_mark_as_favorite' => 'Markeer als favoriet', + 'modal_close_button' => 'Sluiten', + 'modal_save_bookmark_button' => 'Bladwijzer Opslaan', + 'modal_save_changes_button' => 'Wijzigingen Opslaan', + + 'settings_title' => 'Instellingen', + 'settings_general_tab' => 'Algemeen', + 'settings_theme_tab' => 'Thema & Achtergrond', + 'settings_backup_tab' => 'Back-up & Herstel', + 'settings_save_success' => 'Instellingen succesvol opgeslagen.', + 'settings_general_title' => 'Algemene Instellingen', + 'settings_open_new_tab' => 'Bladwijzers in een nieuw tabblad openen', + 'settings_dashboard_protection' => 'Dashboard beveiligen met wachtwoord (vereist opnieuw inloggen)', + 'settings_stay_logged_in' => 'Duur "Ingelogd blijven"', + 'settings_language' => 'Taal', + 'settings_favicon_extractor_url' => 'Favicon Extractor Basis URL', + 'settings_favicon_extractor_url_help' => 'Geef de basis-URL van de service op (bijv. http://localhost:3000). Het pad /favicon/{URL}?larger=true wordt automatisch toegevoegd.', + 'settings_debug_mode' => 'Foutopsporingsmodus inschakelen', + 'settings_save_settings_button' => 'Instellingen Opslaan', + 'settings_user_account_title' => 'Gebruikersaccount', + 'settings_new_password' => 'Nieuw Wachtwoord (laat leeg om huidige te behouden)', + 'settings_confirm_password' => 'Bevestig Nieuw Wachtwoord', + 'settings_update_profile_button' => 'Profiel Bijwerken', + 'settings_user_update_success' => 'Gebruikersprofiel bijgewerkt.', + 'settings_password_mismatch' => 'De nieuwe wachtwoorden komen niet overeen.', + + 'settings_theme_title' => 'Thema & Achtergrond', + 'settings_theme' => 'Thema', + 'settings_theme_light' => 'Licht', + 'settings_theme_dark' => 'Donker', + 'settings_theme_transparent' => 'Transparant', + 'settings_theme_transparency' => 'Thema Transparantie', + 'settings_background_url' => 'Achtergrondafbeelding URL', + 'settings_background_upload' => 'Achtergrond Uploaden', + 'settings_background_current' => 'Huidige achtergrond:', + 'settings_background_optimize' => 'Optimaliseer', + 'settings_background_brightness' => 'Helderheid Achtergrond', + 'settings_background_saturation' => 'Verzadiging Achtergrond', + 'settings_pexels_integration' => 'Pexels Integratie', + 'settings_pexels_api_key' => 'Pexels API Sleutel', + 'settings_pexels_search_placeholder' => 'Zoek een achtergrond op Pexels...', + 'settings_pexels_search_button' => 'Zoeken', + 'settings_custom_css' => 'Aangepaste CSS', + 'settings_custom_footer' => 'Aangepaste Footer HTML', + 'settings_save_theme_button' => 'Thema Instellingen Opslaan', + + 'settings_backup_title' => 'Back-up & Herstel', + 'settings_backup_text' => 'Maak een back-up van je gegevens, inclusief de database en aangepaste afbeeldingen. Je kunt je instantie herstellen vanuit een back-upbestand.', + 'settings_backup_create_button' => 'Back-up Maken', + 'settings_backup_restore_title' => 'Herstellen van Back-up', + 'settings_backup_restore_text' => 'Upload een back-upbestand om je Bookmarkly-instantie te herstellen. Dit overschrijft de huidige gegevens.', + 'settings_backup_restore_input' => 'Back-upbestand (.zip)', + 'settings_backup_restore_button' => 'Back-up terugzetten', + 'settings_backup_restore_success' => 'Back-up succesvol teruggezet. Log opnieuw in.', + 'settings_backup_restore_failed' => 'Terugzetten van back-up mislukt. Zorg ervoor dat het een geldig Bookmarkly back-upbestand is.', + + 'settings_backup_create_title' => 'Back-up Maken', + 'settings_backup_full' => 'Volledige Site (Volledige Back-up)', + 'settings_backup_tab_prefix' => 'Tabblad', + + // System Maintenance + 'settings_maintenance_fix_orphaned_bookmarks_title' => 'Losse Bladwijzers Repareren', + 'settings_maintenance_fix_orphaned_bookmarks_desc' => 'Zoekt naar bladwijzers die niet aan een tabblad zijn toegewezen en wijst ze toe aan het standaard publieke tabblad.', + 'settings_maintenance_fix_orphaned_bookmarks_button' => 'Bladwijzers Repareren', + + // Nieuwe strings voor importfunctie + 'settings_import_title' => 'Bladwijzers importeren in een specifiek tabblad', + 'settings_import_text' => 'Importeer bladwijzers uit een Bookmarkly back-upbestand in een tabblad naar keuze. Dit voegt de bladwijzers en categorieën uit de back-up toe aan het geselecteerde tabblad zonder bestaande gegevens te overschrijven.', + 'settings_import_input' => 'Back-upbestand (.zip)', + 'settings_import_select_tab' => 'Doeltabblad', + 'settings_import_select_tab_placeholder' => '-- Selecteer een tabblad --', + 'settings_import_button' => 'Importeren naar tabblad', + + // Gebruikersbeheer + 'user_management_title' => 'Gebruikersbeheer', + 'user_management_username' => 'Gebruikersnaam', + 'user_settings_existing_users' => 'Bestaande Gebruikers', + 'user_settings_role' => 'Rol', + 'user_settings_roles_title' => 'Rol Permissies', + 'user_settings_role_admin' => 'Admin', + 'user_settings_role_admin_desc' => 'Kan gebruikers, tabbladen, instellingen en alle bladwijzers/categorieën beheren.', + 'user_settings_role_user' => 'User', + 'user_settings_role_user_desc' => 'Kan bladwijzers en categorieën beheren op tabbladen die eigendom zijn. Kan alleen-lezen toegang krijgen tot andere tabbladen.', + 'user_settings_role_viewer' => 'Viewer', + 'user_settings_role_viewer_desc' => 'Kan alleen openbare tabbladen bekijken en specifieke tabbladen waartoe toegang is verleend. Kan niets bewerken.', + + // Tabs + 'tab_default_name' => 'Publiek', + 'tab_unique_url_label' => 'Unieke Inlog URL', + 'tab_generate_token_button' => 'Token Genereren', + 'tab_generate_token_confirm' => 'Weet je zeker dat je een nieuwe inlog-token wilt genereren? Een eventueel bestaande token wordt ongeldig.', + 'tab_clear_token_button' => 'Token Wissen', + 'tab_clear_token_confirm' => 'Weet je zeker dat je de inlog-token wilt wissen? De unieke URL werkt dan niet meer.', + 'tooltip_copy_to_clipboard' => 'Kopieer naar klembord', + 'tooltip_copied' => 'Gekopieerd!', +]; diff --git a/login.php b/login.php new file mode 100644 index 0000000..edbe983 --- /dev/null +++ b/login.php @@ -0,0 +1,94 @@ +prepare("SELECT * FROM users WHERE username = ?"); + $stmt->execute([$username]); + $user = $stmt->fetch(); + + if ($user && password_verify($password, $user['password'])) { + // Regenerate session ID to prevent session fixation + session_regenerate_id(true); + + // Clear any old session data (like guest_tab_id) + $_SESSION = []; + + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + // TODO: Handle "Remember Me" + header('Location: index.php'); + exit; + } else { + $error = translate('error_invalid_credentials'); + } + } +} + +require_once 'include/header.php'; +?> + +
+
+
+
+
+ + + + <?php echo $site_title; ?> Logo + + + +

- login

+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..fd366f1 --- /dev/null +++ b/logout.php @@ -0,0 +1,27 @@ + 'Please fill in all fields.', 'type' => 'warning']; + } elseif ($new_password !== $confirm_password) { + $_SESSION['flash_message'] = ['text' => 'New passwords do not match.', 'type' => 'warning']; + } else { + // Fetch user's current hashed password + $stmt = $pdo->prepare("SELECT password FROM users WHERE id = ?"); + $stmt->execute([$user_id]); + $user = $stmt->fetch(); + + // Verify current password + if ($user && password_verify($current_password, $user['password'])) { + // Hash the new password and update it + $new_hashed_password = password_hash($new_password, PASSWORD_DEFAULT); + $update_stmt = $pdo->prepare("UPDATE users SET password = ? WHERE id = ?"); + if ($update_stmt->execute([$new_hashed_password, $user_id])) { + $_SESSION['flash_message'] = ['text' => 'Password changed successfully.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error changing password.', 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Incorrect current password.', 'type' => 'danger']; + } + } + + header('Location: profile.php'); + exit; +} + +require_once 'include/header.php'; +?> + +
+
+
+

Change Password

+ + + + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+ + diff --git a/settings.php b/settings.php new file mode 100644 index 0000000..6246eed --- /dev/null +++ b/settings.php @@ -0,0 +1,637 @@ +prepare("REPLACE INTO settings (key, value) VALUES (?, ?)"); + + // Loop through all possible keys from the form to save them + $setting_keys = ['site_title', 'remember_me_duration', 'language', 'favicon_extractor_url', 'logo_height']; + foreach ($setting_keys as $key) { + if (isset($_POST[$key])) { + $upsert_stmt->execute([$key, $_POST[$key]]); + } + } + + // Handle checkboxes separately, as they are not sent when unchecked + $checkboxes = ['display_site_title', 'open_new_tab', 'dashboard_protected']; + foreach ($checkboxes as $key) { + $upsert_stmt->execute([$key, isset($_POST[$key]) ? '1' : '0']); + } + + // Handle file uploads moved from 'update_theme' + $uploadDir = 'data/images/custom/'; + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + $allowed_logo_ext = ['png', 'jpg', 'jpeg', 'gif', 'svg']; + $allowed_favicon_ext = ['png', 'ico', 'svg']; + + // Handle Logo Upload + if (isset($_FILES['site_logo']) && $_FILES['site_logo']['error'] === UPLOAD_ERR_OK) { + $logo_ext = strtolower(pathinfo($_FILES['site_logo']['name'], PATHINFO_EXTENSION)); + if (in_array($logo_ext, $allowed_logo_ext)) { + // To avoid caching issues, append a timestamp to the filename + $logo_filename = 'logo.' . $logo_ext; + $logo_path = $uploadDir . $logo_filename; + if (move_uploaded_file($_FILES['site_logo']['tmp_name'], $logo_path)) { + $upsert_stmt->execute(['site_logo', $logo_path . '?v=' . time()]); + } else { + $_SESSION['flash_message'] = ['text' => 'Failed to move uploaded logo.', 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Invalid file type for logo.', 'type' => 'danger']; + } + } + + // Handle Favicon Upload + if (isset($_FILES['site_favicon']) && $_FILES['site_favicon']['error'] === UPLOAD_ERR_OK) { + $favicon_ext = strtolower(pathinfo($_FILES['site_favicon']['name'], PATHINFO_EXTENSION)); + if (in_array($favicon_ext, $allowed_favicon_ext)) { + $favicon_filename = 'favicon.' . $favicon_ext; + $favicon_path = $uploadDir . $favicon_filename; + if (move_uploaded_file($_FILES['site_favicon']['tmp_name'], $favicon_path)) { + $upsert_stmt->execute(['site_favicon', $favicon_path . '?v=' . time()]); + } else { + $_SESSION['flash_message'] = ['text' => 'Failed to move uploaded favicon.', 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Invalid file type for favicon.', 'type' => 'danger']; + } + } + + if (!isset($_SESSION['flash_message'])) { + $_SESSION['flash_message'] = ['text' => translate('settings_save_success'), 'type' => 'success']; + } + + } elseif ($action === 'update_user') { + $username = trim($_POST['username'] ?? ''); + $new_password = $_POST['new_password'] ?? ''; + $confirm_password = $_POST['confirm_password'] ?? ''; + $user_id = $_SESSION['user_id']; + + if (empty($username)) { + $_SESSION['flash_message'] = ['text' => 'Username cannot be empty.', 'type' => 'warning']; + } else { + // Check if password needs to be updated + if (!empty($new_password)) { + if ($new_password === $confirm_password) { + $hashed_password = password_hash($new_password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare("UPDATE users SET username = ?, password = ? WHERE id = ?"); + $stmt->execute([$username, $hashed_password, $user_id]); + $_SESSION['flash_message'] = ['text' => translate('settings_user_update_success'), 'type' => 'success']; + $_SESSION['username'] = $username; // Update session username + } else { + $_SESSION['flash_message'] = ['text' => translate('settings_password_mismatch'), 'type' => 'danger']; + } + } else { + // Update username only + $stmt = $pdo->prepare("UPDATE users SET username = ? WHERE id = ?"); + $stmt->execute([$username, $user_id]); + $_SESSION['flash_message'] = ['text' => translate('settings_user_update_success'), 'type' => 'success']; + $_SESSION['username'] = $username; // Update session username + } + } + } elseif ($action === 'update_theme') { + $upsert_stmt = $pdo->prepare("REPLACE INTO settings (key, value) VALUES (?, ?)"); + $upload_error = false; + + // Handle file uploads + $backgroundUploadDir = 'data/images/backgrounds/'; + if (!is_dir($backgroundUploadDir)) { + mkdir($backgroundUploadDir, 0755, true); + } + + // Handle Background Image Upload + if (isset($_FILES['background_image']) && $_FILES['background_image']['error'] === UPLOAD_ERR_OK) { + $bg_info = getimagesize($_FILES['background_image']['tmp_name']); + if ($bg_info !== false) { // It's an image + // Find and delete any existing global background + $existing_files = glob($backgroundUploadDir . 'global-bg.*'); + foreach ($existing_files as $file_to_delete) { + unlink($file_to_delete); + } + + $bg_ext = strtolower(pathinfo($_FILES['background_image']['name'], PATHINFO_EXTENSION)); + $bg_filename = 'global-bg.' . $bg_ext; + $bg_path = $backgroundUploadDir . $bg_filename; + + if (move_uploaded_file($_FILES['background_image']['tmp_name'], $bg_path)) { + // Save the new path with a cache-busting query string + $_POST['background_image_url'] = $bg_path . '?v=' . time(); + } else { + $_SESSION['flash_message'] = ['text' => 'Failed to move uploaded background image.', 'type' => 'danger']; + $upload_error = true; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Invalid file type for background. Please upload an image.', 'type' => 'danger']; + $upload_error = true; + } + } + + $theme_keys = ['theme', 'background_image_url', 'background_brightness', 'background_saturation', 'pexels_api_key', 'custom_css', 'theme_transparency', 'custom_footer_html']; + foreach ($theme_keys as $key) { + if (isset($_POST[$key])) { + $upsert_stmt->execute([$key, trim($_POST[$key])]); + } + } + + if (!$upload_error) { + $_SESSION['flash_message'] = ['text' => 'Theme settings saved successfully.', 'type' => 'success']; + } + } elseif ($action === 'restore_backup') { + if (isset($_FILES['backup_file']) && $_FILES['backup_file']['error'] === UPLOAD_ERR_OK) { + $file = $_FILES['backup_file']['tmp_name']; + $zip = new ZipArchive; + if ($zip->open($file) === TRUE) { + // Check for essential files/directories in the zip + if ($zip->locateName('database/bookmarkly.db') !== false) { + // Correct paths for scripts running in the root directory + $dataDir = 'data'; + $extractPath = 'data/'; + + // Important: ensure the old data is removed or backed up before extraction + // A more robust solution would backup the current 'data' dir first. + + // Clear the data directory before restoring, only if it exists + if (is_dir($dataDir)) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dataDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $fileinfo) { + if ($fileinfo->isDir()) { + @rmdir($fileinfo->getRealPath()); + } else { + @unlink($fileinfo->getRealPath()); + } + } + } + // Ensure the target directory exists + if (!is_dir($extractPath)) { + mkdir($extractPath, 0755, true); + } + + // Extract the new data + $zip->extractTo($extractPath); + $zip->close(); + + session_destroy(); + $_SESSION['flash_message'] = ['text' => translate('settings_backup_restore_success'), 'type' => 'success']; + header('Location: login.php'); + exit; + } else { + $_SESSION['flash_message'] = ['text' => translate('settings_backup_restore_failed'), 'type' => 'danger']; + } + $zip->close(); + } else { + $_SESSION['flash_message'] = ['text' => translate('settings_backup_restore_failed'), 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => translate('settings_backup_restore_failed'), 'type' => 'danger']; + } + } + + // Redirect back to settings page for all actions except a successful restore + header('Location: settings.php'); + exit; +} + +// Fetch tabs for the new import form +$tabs_stmt = $pdo->prepare("SELECT id, name FROM tabs"); +$tabs_stmt->execute(); +$tabs = $tabs_stmt->fetchAll(); + +require_once 'include/header.php'; +?> + +
+

+ + + + + +
+
+ +
+
+
+ +
+
+
+

+
+
+
+ + +
+ + +
+
+ > + +
+
+ + + + Current logo: Current Logo + +
+
+ + +
+
+ + + + Current favicon: Current Favicon + +
+
+ +
+ > + +
+
+ > + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
+

+
+
+

These settings serve as the global default. They can be overridden by individual tabs in the Tab Management page.

+
+
+ + +
+ + +
+
+ + +
+
+ + + + + + Current Background + + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ > + +
+
+
+
+
+ + +
+
+
+
+ + +
+ +
+
+
+
+ +
+
+
+

+
+
+
+

+
+
+ + +
+
+ +
+ +
+

+
+ +
+ + +
+ +
+ +
+ +
+

+
+
+ + +
+
+ + +
+ +
+
+
+
+ + +
+
+
+

System Maintenance

+
+
+
Update Database Schema
+

If new features require database changes, you can apply them here. This process is safe to run multiple times.

+ +
+ +
+ +
+

+ +
+
+
+
+ +
+
+
+
+ + + diff --git a/tabs_settings.php b/tabs_settings.php new file mode 100644 index 0000000..9b55af4 --- /dev/null +++ b/tabs_settings.php @@ -0,0 +1,702 @@ +query("SELECT id, username, role FROM users ORDER BY username"); +$users = $users_stmt->fetchAll(); + +require_once 'include/header.php'; +?> + +
+

Manage Tabs

+ + +
+
Add New Tab
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
Existing Tabs & Permissions
+
+
+ +
+
+
+
+ + + + + + + + + + diff --git a/user_settings.php b/user_settings.php new file mode 100644 index 0000000..8a488a4 --- /dev/null +++ b/user_settings.php @@ -0,0 +1,252 @@ +prepare("SELECT id FROM users WHERE username = ?"); + $stmt->execute([$username]); + if ($stmt->fetch()) { + $_SESSION['flash_message'] = ['text' => 'Username already exists.', 'type' => 'warning']; + } else { + $hashed_password = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare("INSERT INTO users (username, password, role) VALUES (?, ?, ?)"); + if ($stmt->execute([$username, $hashed_password, $role])) { + $_SESSION['flash_message'] = ['text' => 'User added successfully.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error adding user.', 'type' => 'danger']; + } + } + } else { + $_SESSION['flash_message'] = ['text' => 'Username, password, and a valid role are required.', 'type' => 'warning']; + } + } elseif ($action === 'edit_user') { + $user_id = filter_input(INPUT_POST, 'user_id', FILTER_VALIDATE_INT); + $username = trim($_POST['username'] ?? ''); + $role = $_POST['role'] ?? 'user'; + $password = $_POST['password'] ?? ''; // Optional: for changing password + + if ($user_id && !empty($username) && in_array($role, ['user', 'viewer', 'admin'])) { + // Check if new username already exists (and is not the current user) + $stmt = $pdo->prepare("SELECT id FROM users WHERE username = ? AND id != ?"); + $stmt->execute([$username, $user_id]); + if ($stmt->fetch()) { + $_SESSION['flash_message'] = ['text' => 'Username already exists.', 'type' => 'warning']; + } else { + if (!empty($password)) { + // If password is provided, update it + $hashed_password = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare("UPDATE users SET username = ?, role = ?, password = ? WHERE id = ?"); + $stmt->execute([$username, $role, $hashed_password, $user_id]); + } else { + // Otherwise, update without changing password + $stmt = $pdo->prepare("UPDATE users SET username = ?, role = ? WHERE id = ?"); + $stmt->execute([$username, $role, $user_id]); + } + $_SESSION['flash_message'] = ['text' => 'User updated successfully.', 'type' => 'success']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Invalid data for updating user.', 'type' => 'warning']; + } + } elseif ($action === 'delete_user') { + $user_id = filter_input(INPUT_POST, 'user_id', FILTER_VALIDATE_INT); + if ($user_id && $user_id != 1) { // Prevent admin from deleting themselves + $stmt = $pdo->prepare("DELETE FROM users WHERE id = ?"); + if ($stmt->execute([$user_id])) { + $_SESSION['flash_message'] = ['text' => 'User deleted successfully.', 'type' => 'success']; + } else { + $_SESSION['flash_message'] = ['text' => 'Error deleting user.', 'type' => 'danger']; + } + } else { + $_SESSION['flash_message'] = ['text' => 'Invalid user or action not allowed.', 'type' => 'danger']; + } + } + + header('Location: user_settings.php'); + exit; +} + +// Fetch all users for display +$users_stmt = $pdo->query("SELECT id, username, role FROM users ORDER BY username"); +$users = $users_stmt->fetchAll(); + +require_once 'include/header.php'; +?> + +
+

Manage Users

+ + + + + + +
+
Add New User
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
Existing Users
+
+
    + +
  • +
    + + +
    + +
    + +
    + + + +
    +
    + +
  • + +
+
+
+ + +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + + + + +