diff --git a/README.md b/README.md new file mode 100644 index 0000000..5197198 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Pastebin + +A lightweight, self-hosted Pastebin application built with PHP and MySQL. +No build step required — just drop the files on any PHP-enabled web server. + +## Features + +- **Create, view, and delete** text/code pastes +- **Syntax highlighting** for 19 languages powered by [highlight.js](https://highlightjs.org/) +- **Anonymous comments** on every paste +- **Delete-token authentication** — session flash on creation, long-lived cookie fallback +- **Paginated paste listing** — browse all pastes with Prev/Next controls in the sidebar +- **Responsive UI** — mobile-first layout built with [Tailwind CSS](https://tailwindcss.com/) +- **Raw endpoint** — `?raw=SLUG` returns plain-text content (great for `curl`) +- **Zero dependencies** — no Composer, no npm; CDN assets only + +## Directory Structure + +``` +pastebin/ +├── config/ +│ └── config.php # DB credentials & application constants +├── src/ +│ ├── db.php # PDO factory + schema auto-creation +│ ├── helpers.php # Slug/token generation, pagination helpers, language list +│ └── actions.php # POST request handlers (create, delete, add_comment, raw) +├── views/ +│ ├── layout_head.php # HTML + sticky top navbar +│ ├── layout_foot.php # Footer + global JavaScript +│ ├── home.php # Create-paste form & error/success notices +│ ├── paste_view.php # View paste, comments, and delete form +│ └── sidebar.php # Paginated recent-pastes sidebar +├── index.php # Application entry point (routing + data fetching) +├── LICENSE +└── README.md +``` + +## Installation + +1. **Clone the repository** + + ```bash + git clone https://github.com/druvx13/pastebin.git + cd pastebin + ``` + +2. **Configure the database** — edit `config/config.php`: + + ```php + define('DB_HOST', '127.0.0.1'); + define('DB_USER', 'your_db_user'); + define('DB_PASS', 'your_db_password'); + define('DB_NAME', 'pastebin_app'); + ``` + +3. **Deploy** the directory to a PHP-enabled web server with the project root + as the document root (or set up a virtual host pointing to it). + +4. **Visit the site** — the database and tables are created automatically on + the first request. + +## Requirements + +| Requirement | Minimum version | +|---|---| +| PHP | 7.4 | +| MySQL / MariaDB | MySQL 5.7 / MariaDB 10.3 | +| PHP extensions | `pdo`, `pdo_mysql`, `mbstring` | + +## Configuration Reference + +All configuration lives in `config/config.php`: + +| Constant | Default | Description | +|---|---|---| +| `DB_HOST` | `127.0.0.1` | MySQL host | +| `DB_PORT` | `3306` | MySQL port | +| `DB_USER` | `root` | MySQL username | +| `DB_PASS` | `password` | MySQL password (**change before deploying**) | +| `DB_NAME` | `pastebin_app` | Database name (auto-created if absent) | +| `PASTES_PER_PAGE` | `15` | Pastes shown per page in the sidebar | +| `COOKIE_LIFETIME` | `2592000` | Cookie lifetime in seconds (default 30 days) | +| `COMMENT_MAX_LENGTH` | `2000` | Maximum comment body length (characters) | +| `COMMENT_NAME_MAX` | `100` | Maximum commenter name length (characters) | + +## Usage + +| Task | How | +|---|---| +| **Create a paste** | Fill in the form on the homepage and click **Create Paste** | +| **View a paste** | Click any entry in the sidebar, or navigate to `/?view=SLUG` | +| **Copy content** | Click the **Copy** button on the paste page | +| **Raw content** | Click **Raw** or visit `/?raw=SLUG` (returns `text/plain`) | +| **Delete a paste** | Use the delete form at the bottom of the paste page | +| **Browse past pastes** | Use the **← Prev** / **Next →** controls in the sidebar | +| **Comment on a paste** | Use the comment form on any paste page | + +## Security Notes + +- **Change the default database credentials** in `config/config.php` before any + public deployment. +- **Enable HTTPS** in production to protect tokens and content in transit. +- Delete tokens use [`hash_equals()`](https://www.php.net/hash_equals) to + prevent timing-attack leakage. +- All user-supplied content is escaped with `htmlspecialchars()` at render time. +- Cookies set by the application use `SameSite=Lax` in the JavaScript layer; + consider setting the `Secure` flag on the PHP `setcookie()` calls when + serving over HTTPS. + +## License + +[MIT](LICENSE) diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..bd153f4 --- /dev/null +++ b/config/config.php @@ -0,0 +1,28 @@ + + sticky navbar + * views/layout_foot.php — footer + global JS + + * views/home.php — create-paste form + * views/paste_view.php — view paste, comments, delete form + * views/sidebar.php — paginated recent-pastes sidebar + * + * Installation + * ------------ + * 1. Edit DB_HOST / DB_USER / DB_PASS / DB_NAME in config/config.php. + * 2. Point a PHP-enabled web server at this directory. + * 3. Open the site — the database and tables are auto-created on first load. + */ + +/* ---- Session (needed for one-time delete-token flash) ---- */ if (session_status() === PHP_SESSION_NONE) { session_start(); } -/* =========================== - DATABASE CONFIGURATION - =========================== */ -define('DB_HOST', '127.0.0.1'); // MySQL host -define('DB_PORT', '3306'); // MySQL port -define('DB_USER', 'root'); // MySQL username -define('DB_PASS', 'password'); // MySQL password -define('DB_NAME', 'pastebin_app'); // Desired DB name (auto-created if allowed) - -/* =========================== - APP CONSTANTS - =========================== */ -define('TABLE_PASTES', 'pastes'); -define('TABLE_COMMENTS', 'comments'); -define('SLUG_LENGTH_BYTES', 5); // slug length (bytes -> hex chars = *2) -define('DELETE_TOKEN_BYTES', 12); // delete token bytes (hex) -define('RECENT_COUNT', 20); // number of recent pastes on homepage -define('COOKIE_LIFETIME', 30*24*3600); // cookie lifetime for paste tokens (30 days) -define('COMMENT_MAX_LENGTH', 2000); -define('COMMENT_NAME_MAX', 100); - -/* =========================== - BOOTSTRAP: CONNECT & INIT - =========================== */ -try { - // Connect without DB to allow DB creation if needed - $dsnNoDB = sprintf('mysql:host=%s;port=%s;charset=utf8mb4', DB_HOST, DB_PORT); - $pdo = new PDO($dsnNoDB, DB_USER, DB_PASS, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); - - // Create DB if missing - $safeDb = str_replace('`', '``', DB_NAME); - $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$safeDb}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); - - // Reconnect with DB selected - $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, DB_NAME); - $pdo = new PDO($dsn, DB_USER, DB_PASS, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); - - // Create pastes table if not exists - $createPastesSQL = " - CREATE TABLE IF NOT EXISTS `" . TABLE_PASTES . "` ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - slug VARCHAR(64) NOT NULL UNIQUE, - title VARCHAR(255) DEFAULT NULL, - language VARCHAR(64) DEFAULT 'text', - content LONGTEXT NOT NULL, - delete_token VARCHAR(128) NOT NULL, - views INT UNSIGNED NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX (created_at) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - "; - $pdo->exec($createPastesSQL); - - // Create comments table if not exists (anonymous comments) - // columns: id, paste_id (FK), name (nullable), message, created_at - $createCommentsSQL = " - CREATE TABLE IF NOT EXISTS `" . TABLE_COMMENTS . "` ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - paste_id BIGINT UNSIGNED NOT NULL, - name VARCHAR(150) DEFAULT NULL, - message TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX (paste_id), - FOREIGN KEY (paste_id) REFERENCES `" . TABLE_PASTES . "` (id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - "; - $pdo->exec($createCommentsSQL); - -} catch (PDOException $e) { - http_response_code(500); - echo "

Database error

" . htmlspecialchars($e->getMessage()) . "
"; - exit; -} - -/* =========================== - HELPERS - =========================== */ - -/** Generate a unique slug (hex). Retries then fallback. */ -function generate_unique_slug(PDO $pdo) { - for ($i=0; $i<8; $i++) { - $slug = bin2hex(random_bytes(SLUG_LENGTH_BYTES)); - $stmt = $pdo->prepare("SELECT 1 FROM `" . TABLE_PASTES . "` WHERE slug = :s LIMIT 1"); - $stmt->execute([':s' => $slug]); - if (!$stmt->fetch()) return $slug; - } - return preg_replace('/[^a-z0-9]/', '', uniqid('', true)); -} - -/** Generate a delete token (hex) */ -function generate_delete_token() { - return bin2hex(random_bytes(DELETE_TOKEN_BYTES)); -} - -/* Language options for select and highlight classes */ -$languages = [ - 'text' => 'Plain Text', - 'bash' => 'Bash', - 'json' => 'JSON', - 'xml' => 'XML', - 'html' => 'HTML', - 'css' => 'CSS', - 'javascript' => 'JavaScript', - 'typescript' => 'TypeScript', - 'php' => 'PHP', - 'python' => 'Python', - 'java' => 'Java', - 'c' => 'C', - 'cpp' => 'C++', - 'csharp' => 'C#', - 'go' => 'Go', - 'ruby' => 'Ruby', - 'rust' => 'Rust', - 'kotlin' => 'Kotlin', - 'sql' => 'SQL', -]; - -/* Base path for links */ -$basePath = strtok($_SERVER["REQUEST_URI"], '?'); - -/* =========================== - REQUEST HANDLERS - =========================== */ - -/* 1) Create paste (improved: session flash + cookie) */ -if (isset($_POST['action']) && $_POST['action'] === 'create') { - $title = isset($_POST['title']) ? trim($_POST['title']) : null; - $language = isset($_POST['language']) && array_key_exists($_POST['language'], $languages) ? $_POST['language'] : 'text'; - $content = isset($_POST['content']) ? trim($_POST['content']) : ''; - - if ($content === '') { - header("Location: " . $basePath . "?err=empty"); - exit; - } - - try { - $slug = generate_unique_slug($pdo); - $delete_token = generate_delete_token(); - - $stmt = $pdo->prepare("INSERT INTO `" . TABLE_PASTES . "` (slug, title, language, content, delete_token) VALUES (:slug, :title, :lang, :content, :dt)"); - $stmt->execute([ - ':slug' => $slug, - ':title' => $title ?: null, - ':lang' => $language, - ':content' => $content, - ':dt' => $delete_token - ]); - - // session flash & cookie for convenience - $_SESSION['last_paste'] = ['slug' => $slug, 'delete_token' => $delete_token]; - setcookie('paste_token_' . $slug, $delete_token, time() + COOKIE_LIFETIME, "/", "", false, false); - - header("Location: " . $basePath . "?view=" . urlencode($slug) . "&created=1"); - exit; - } catch (PDOException $e) { - header("Location: " . $basePath . "?err=db"); - exit; - } -} - -/* 2) Delete paste (accept token or cookie fallback) */ -if (isset($_POST['action']) && $_POST['action'] === 'delete') { - $slug = isset($_POST['slug']) ? $_POST['slug'] : ''; - $provided_token = isset($_POST['token']) ? $_POST['token'] : ''; - - if ($slug === '') { - header("Location: " . $basePath . "?err=delparams"); - exit; - } - - try { - $stmt = $pdo->prepare("SELECT delete_token FROM `" . TABLE_PASTES . "` WHERE slug = :s LIMIT 1"); - $stmt->execute([':s' => $slug]); - $row = $stmt->fetch(); - if (!$row) { - header("Location: " . $basePath . "?err=notfound"); - exit; - } - $stored_token = $row['delete_token']; - $allowed = false; - - if ($provided_token !== '') { - if (hash_equals($stored_token, $provided_token)) $allowed = true; - } - if (!$allowed) { - $cookieName = 'paste_token_' . $slug; - if (isset($_COOKIE[$cookieName]) && is_string($_COOKIE[$cookieName]) && $_COOKIE[$cookieName] !== '') { - if (hash_equals($stored_token, $_COOKIE[$cookieName])) $allowed = true; - } - } - - if (!$allowed) { - header("Location: " . $basePath . "?err=badtoken"); - exit; - } - - $del = $pdo->prepare("DELETE FROM `" . TABLE_PASTES . "` WHERE slug = :s"); - $del->execute([':s' => $slug]); - setcookie('paste_token_' . $slug, '', time() - 3600, "/", "", false, false); - - header("Location: " . $basePath . "?deleted=1"); - exit; - } catch (PDOException $e) { - header("Location: " . $basePath . "?err=db"); - exit; +/* ---- Bootstrap ---- */ +require_once __DIR__ . '/config/config.php'; +require_once __DIR__ . '/src/db.php'; +require_once __DIR__ . '/src/helpers.php'; +require_once __DIR__ . '/src/actions.php'; + +$pdo = db_connect(); +$languages = get_languages(); +$basePath = strtok($_SERVER['REQUEST_URI'], '?'); + +/* ---- Route POST actions (PRG pattern) ---- */ +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = isset($_POST['action']) ? $_POST['action'] : ''; + switch ($action) { + case 'create': handle_create($pdo, $basePath, $languages); break; + case 'delete': handle_delete($pdo, $basePath); break; + case 'add_comment': handle_add_comment($pdo, $basePath); break; } } -/* 3) Add anonymous comment (action=add_comment) - - expects: slug (paste slug), message (required), name (optional) - - stores comment linked to paste.id -*/ -if (isset($_POST['action']) && $_POST['action'] === 'add_comment') { - $slug = isset($_POST['slug']) ? $_POST['slug'] : ''; - $commenter_name = isset($_POST['commenter_name']) ? trim($_POST['commenter_name']) : null; - $comment_msg = isset($_POST['comment_msg']) ? trim($_POST['comment_msg']) : ''; - - if ($slug === '' || $comment_msg === '') { - header("Location: " . $basePath . "?err=commentparams"); - exit; - } - if (mb_strlen($comment_msg) > COMMENT_MAX_LENGTH) { - header("Location: " . $basePath . "?view=" . urlencode($slug) . "&err=commenttoolong"); - exit; - } - if ($commenter_name !== null && mb_strlen($commenter_name) > COMMENT_NAME_MAX) { - $commenter_name = mb_substr($commenter_name, 0, COMMENT_NAME_MAX); - } - - try { - // get paste id - $stmt = $pdo->prepare("SELECT id FROM `" . TABLE_PASTES . "` WHERE slug = :s LIMIT 1"); - $stmt->execute([':s' => $slug]); - $row = $stmt->fetch(); - if (!$row) { - header("Location: " . $basePath . "?err=notfound"); - exit; - } - $paste_id = (int)$row['id']; - - // insert comment - $ins = $pdo->prepare("INSERT INTO `" . TABLE_COMMENTS . "` (paste_id, name, message) VALUES (:pid, :n, :m)"); - $ins->execute([ - ':pid' => $paste_id, - ':n' => $commenter_name ?: null, - ':m' => $comment_msg - ]); - - // optional: store commenter name in cookie to prefill next time - if ($commenter_name && $commenter_name !== '') { - setcookie('commenter_name', $commenter_name, time() + COOKIE_LIFETIME, "/", "", false, false); - } - - // redirect back to paste with anchor for comments - header("Location: " . $basePath . "?view=" . urlencode($slug) . "#comments"); - exit; - } catch (PDOException $e) { - header("Location: " . $basePath . "?err=db"); - exit; - } -} - -/* Raw endpoint: ?raw=SLUG (plaintext) */ +/* ---- Raw-content endpoint: ?raw=SLUG ---- */ if (isset($_GET['raw'])) { - $slug = $_GET['raw']; - $stmt = $pdo->prepare("SELECT content FROM `" . TABLE_PASTES . "` WHERE slug = :s LIMIT 1"); - $stmt->execute([':s' => $slug]); - $row = $stmt->fetch(); - if (!$row) { - http_response_code(404); - header('Content-Type: text/plain; charset=utf-8'); - echo "Not found"; - exit; - } - // increment views - $upd = $pdo->prepare("UPDATE `" . TABLE_PASTES . "` SET views = views + 1 WHERE slug = :s"); - $upd->execute([':s' => $slug]); - header('Content-Type: text/plain; charset=utf-8'); - echo $row['content']; - exit; + handle_raw($pdo); + // handle_raw() always exits } -/* View paste (if ?view=SLUG) - fetch paste and its comments */ +/* ---- View a paste: ?view=SLUG ---- */ $viewSlug = isset($_GET['view']) ? $_GET['view'] : null; -$paste = null; +$paste = null; $comments = []; + if ($viewSlug) { - $stmt = $pdo->prepare("SELECT id, slug, title, language, content, views, created_at, delete_token FROM `" . TABLE_PASTES . "` WHERE slug = :s LIMIT 1"); + $stmt = $pdo->prepare( + 'SELECT id, slug, title, language, content, views, created_at, delete_token + FROM `' . TABLE_PASTES . '` + WHERE slug = :s LIMIT 1' + ); $stmt->execute([':s' => $viewSlug]); $paste = $stmt->fetch(); if ($paste) { - // increment views - $upd = $pdo->prepare("UPDATE `" . TABLE_PASTES . "` SET views = views + 1 WHERE id = :id"); + // Increment view counter + $upd = $pdo->prepare( + 'UPDATE `' . TABLE_PASTES . '` SET views = views + 1 WHERE id = :id' + ); $upd->execute([':id' => $paste['id']]); $paste['views'] += 1; - // fetch comments for this paste - $cstmt = $pdo->prepare("SELECT id, name, message, created_at FROM `" . TABLE_COMMENTS . "` WHERE paste_id = :pid ORDER BY created_at ASC"); + // Fetch comments (oldest first) + $cstmt = $pdo->prepare( + 'SELECT id, name, message, created_at + FROM `' . TABLE_COMMENTS . '` + WHERE paste_id = :pid + ORDER BY created_at ASC' + ); $cstmt->execute([':pid' => $paste['id']]); $comments = $cstmt->fetchAll(); } else { - $viewSlug = null; // not found + $viewSlug = null; // slug not found — fall through to homepage } } -/* Recent pastes for sidebar */ -function fetch_recent(PDO $pdo, $count = RECENT_COUNT) { - $stmt = $pdo->prepare("SELECT slug, title, language, created_at FROM `" . TABLE_PASTES . "` ORDER BY created_at DESC LIMIT :cnt"); - $stmt->bindValue(':cnt', (int)$count, PDO::PARAM_INT); - $stmt->execute(); - return $stmt->fetchAll(); -} -$recentPastes = fetch_recent($pdo); - -/* =========================== - RENDER HTML UI (monolithic) - =========================== */ -?> - - - - - Pastebin — Single-file - - - - - - - - - - - - - - - -
-
- - -
-
-
-

Pastebin — Single-file

-

Create, view, delete pastes. Anonymous comments available per paste.

-
-
- PHP + MySQL + Tailwind + highlight.js -
-
-
- -
- -
- - - - -
-
-

-
- Language: -  •  - Created: -  •  - Views: -
-
- -
- Raw - -
-
- - -
-
Delete token (store securely)
-
- - -
-
This token is required to delete the paste if you don't use the same browser. It is shown once after creation.
-
- - -
-
-
+/* ---- Page title ---- */ +$pageTitle = $paste + ? ($paste['title'] ?: 'Untitled paste') + : 'New Paste'; - -
-

Comments ()

+/* ---- Render ---- */ +include __DIR__ . '/views/layout_head.php'; - -
- - - -
- -
- -
-
- -
- - -
Comments are anonymous; provide a name to display it.
-
-
- - - -
No comments yet. Be the first to comment.
- -
- -
-
-
-
-
-
-
- -
- - -
- - -
-

To delete this paste, enter the delete token (shown above at creation) or use the same browser (cookie-based). If you don't have either, contact the site admin or remove via DB.

- -
- - - - -
-
- - - - - -

Create a new paste

- - -
- -
- -
Paste deleted.
- - -
- -
- - -
- -
-
- - -
-
Tip: Use Raw link to fetch plain content programmatically.
-
- -
- - -
- -
- - -
A delete token will be shown once after creation and stored in a cookie for this browser.
-
-
- - -
- - - -
- - -
-
- - - - - +include __DIR__ . '/views/layout_foot.php'; diff --git a/src/actions.php b/src/actions.php new file mode 100644 index 0000000..0018086 --- /dev/null +++ b/src/actions.php @@ -0,0 +1,237 @@ +prepare( + 'INSERT INTO `' . TABLE_PASTES . '` + (slug, title, language, content, delete_token) + VALUES (:slug, :title, :lang, :content, :dt)' + ); + $stmt->execute([ + ':slug' => $slug, + ':title' => $title ?: null, + ':lang' => $language, + ':content' => $content, + ':dt' => $delete_token, + ]); + + // Session flash (one-time display) + long-lived cookie fallback + $_SESSION['last_paste'] = ['slug' => $slug, 'delete_token' => $delete_token]; + setcookie( + 'paste_token_' . $slug, + $delete_token, + time() + COOKIE_LIFETIME, + '/', '', false, false + ); + + header('Location: ' . $basePath . '?view=' . urlencode($slug) . '&created=1'); + exit; + + } catch (PDOException $e) { + header('Location: ' . $basePath . '?err=db'); + exit; + } +} + +/** + * Handle POST action=delete. + * Verifies the supplied token (or cookie fallback), deletes the paste, then + * redirects to the homepage with a success flag. + * + * @param PDO $pdo + * @param string $basePath + */ +function handle_delete(PDO $pdo, string $basePath): void +{ + $slug = isset($_POST['slug']) ? $_POST['slug'] : ''; + $provided_token = isset($_POST['token']) ? $_POST['token'] : ''; + + if ($slug === '') { + header('Location: ' . $basePath . '?err=delparams'); + exit; + } + + try { + $stmt = $pdo->prepare( + 'SELECT delete_token FROM `' . TABLE_PASTES . '` + WHERE slug = :s LIMIT 1' + ); + $stmt->execute([':s' => $slug]); + $row = $stmt->fetch(); + + if (!$row) { + header('Location: ' . $basePath . '?err=notfound'); + exit; + } + + $stored_token = $row['delete_token']; + $allowed = false; + + // Check explicitly provided token first + if ($provided_token !== '' && hash_equals($stored_token, $provided_token)) { + $allowed = true; + } + + // Cookie fallback + if (!$allowed) { + $cookieName = 'paste_token_' . $slug; + if ( + isset($_COOKIE[$cookieName]) && + is_string($_COOKIE[$cookieName]) && + $_COOKIE[$cookieName] !== '' && + hash_equals($stored_token, $_COOKIE[$cookieName]) + ) { + $allowed = true; + } + } + + if (!$allowed) { + header('Location: ' . $basePath . '?err=badtoken'); + exit; + } + + $del = $pdo->prepare('DELETE FROM `' . TABLE_PASTES . '` WHERE slug = :s'); + $del->execute([':s' => $slug]); + setcookie('paste_token_' . $slug, '', time() - 3600, '/', '', false, false); + + header('Location: ' . $basePath . '?deleted=1'); + exit; + + } catch (PDOException $e) { + header('Location: ' . $basePath . '?err=db'); + exit; + } +} + +/** + * Handle POST action=add_comment. + * Validates input, inserts an anonymous comment linked to the paste, + * optionally stores the commenter name in a cookie, then redirects back + * to the paste page anchored at #comments. + * + * @param PDO $pdo + * @param string $basePath + */ +function handle_add_comment(PDO $pdo, string $basePath): void +{ + $slug = isset($_POST['slug']) ? $_POST['slug'] : ''; + $commenter_name = isset($_POST['commenter_name']) ? trim($_POST['commenter_name']) : null; + $comment_msg = isset($_POST['comment_msg']) ? trim($_POST['comment_msg']) : ''; + + if ($slug === '' || $comment_msg === '') { + header('Location: ' . $basePath . '?err=commentparams'); + exit; + } + + if (mb_strlen($comment_msg) > COMMENT_MAX_LENGTH) { + header('Location: ' . $basePath . '?view=' . urlencode($slug) . '&err=commenttoolong'); + exit; + } + + if ($commenter_name !== null && mb_strlen($commenter_name) > COMMENT_NAME_MAX) { + $commenter_name = mb_substr($commenter_name, 0, COMMENT_NAME_MAX); + } + + try { + $stmt = $pdo->prepare( + 'SELECT id FROM `' . TABLE_PASTES . '` WHERE slug = :s LIMIT 1' + ); + $stmt->execute([':s' => $slug]); + $row = $stmt->fetch(); + + if (!$row) { + header('Location: ' . $basePath . '?err=notfound'); + exit; + } + + $paste_id = (int) $row['id']; + $ins = $pdo->prepare( + 'INSERT INTO `' . TABLE_COMMENTS . '` + (paste_id, name, message) + VALUES (:pid, :n, :m)' + ); + $ins->execute([ + ':pid' => $paste_id, + ':n' => $commenter_name ?: null, + ':m' => $comment_msg, + ]); + + if ($commenter_name && $commenter_name !== '') { + setcookie( + 'commenter_name', + $commenter_name, + time() + COOKIE_LIFETIME, + '/', '', false, false + ); + } + + header('Location: ' . $basePath . '?view=' . urlencode($slug) . '#comments'); + exit; + + } catch (PDOException $e) { + header('Location: ' . $basePath . '?err=db'); + exit; + } +} + +/** + * Handle GET ?raw=SLUG. + * Returns the paste content as plain text and increments the view counter. + * + * @param PDO $pdo + */ +function handle_raw(PDO $pdo): void +{ + $slug = $_GET['raw']; + $stmt = $pdo->prepare( + 'SELECT content FROM `' . TABLE_PASTES . '` WHERE slug = :s LIMIT 1' + ); + $stmt->execute([':s' => $slug]); + $row = $stmt->fetch(); + + if (!$row) { + http_response_code(404); + header('Content-Type: text/plain; charset=utf-8'); + echo 'Not found'; + exit; + } + + $upd = $pdo->prepare( + 'UPDATE `' . TABLE_PASTES . '` SET views = views + 1 WHERE slug = :s' + ); + $upd->execute([':s' => $slug]); + + header('Content-Type: text/plain; charset=utf-8'); + echo $row['content']; + exit; +} diff --git a/src/db.php b/src/db.php new file mode 100644 index 0000000..0a1e24f --- /dev/null +++ b/src/db.php @@ -0,0 +1,80 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + $safeDb = str_replace('`', '``', DB_NAME); + $pdo->exec( + "CREATE DATABASE IF NOT EXISTS `{$safeDb}` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + + // Reconnect with the target database selected + $dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', + DB_HOST, DB_PORT, DB_NAME + ); + $pdo = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + // Pastes table + $pdo->exec(" + CREATE TABLE IF NOT EXISTS `" . TABLE_PASTES . "` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL UNIQUE, + title VARCHAR(255) DEFAULT NULL, + language VARCHAR(64) DEFAULT 'text', + content LONGTEXT NOT NULL, + delete_token VARCHAR(128) NOT NULL, + views INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // Comments table (cascade-deletes when a paste is removed) + $pdo->exec(" + CREATE TABLE IF NOT EXISTS `" . TABLE_COMMENTS . "` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + paste_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(150) DEFAULT NULL, + message TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX (paste_id), + FOREIGN KEY (paste_id) + REFERENCES `" . TABLE_PASTES . "` (id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + return $pdo; + + } catch (PDOException $e) { + http_response_code(500); + echo '

Database error

'
+            . htmlspecialchars($e->getMessage()) . '
'; + exit; + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..249780c --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,98 @@ +prepare( + 'SELECT 1 FROM `' . TABLE_PASTES . '` WHERE slug = :s LIMIT 1' + ); + $stmt->execute([':s' => $slug]); + if (!$stmt->fetch()) { + return $slug; + } + } + // Fallback (extremely unlikely) + return preg_replace('/[^a-z0-9]/', '', uniqid('', true)); +} + +/** + * Generate a cryptographically random delete token (hex string). + */ +function generate_delete_token(): string +{ + return bin2hex(random_bytes(DELETE_TOKEN_BYTES)); +} + +/** + * Fetch a paginated list of pastes ordered by most-recent first. + * + * @param PDO $pdo + * @param int $page 1-based page number + * @param int $perPage Rows per page (defaults to PASTES_PER_PAGE) + * @return array + */ +function fetch_pastes(PDO $pdo, int $page = 1, int $perPage = PASTES_PER_PAGE): array +{ + $offset = ($page - 1) * $perPage; + $stmt = $pdo->prepare( + 'SELECT slug, title, language, created_at + FROM `' . TABLE_PASTES . '` + ORDER BY created_at DESC + LIMIT :lim OFFSET :off' + ); + $stmt->bindValue(':lim', $perPage, PDO::PARAM_INT); + $stmt->bindValue(':off', $offset, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); +} + +/** + * Return the total number of pastes stored in the database. + */ +function count_pastes(PDO $pdo): int +{ + return (int) $pdo->query('SELECT COUNT(*) FROM `' . TABLE_PASTES . '`') + ->fetchColumn(); +} + +/** + * Return the canonical language map used in the create-form +
+ + +
+ +
+
+ + +
+
Tip: Use Raw link to fetch plain content programmatically.
+
+ +
+ + +
+ +
+ + +
A delete token will be shown once after creation and stored in a cookie for this browser.
+
+ diff --git a/views/layout_foot.php b/views/layout_foot.php new file mode 100644 index 0000000..0e09488 --- /dev/null +++ b/views/layout_foot.php @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + diff --git a/views/layout_head.php b/views/layout_head.php new file mode 100644 index 0000000..fc68218 --- /dev/null +++ b/views/layout_head.php @@ -0,0 +1,44 @@ + + + + + + <?php echo isset($pageTitle) ? htmlspecialchars($pageTitle) . ' — Pastebin' : 'Pastebin'; ?> + + + + + + + + + + + + + + + +
+
+ + +
+
+
+

+ Pastebin +

+

Create, view, delete pastes. Anonymous comments available per paste.

+
+
+ PHP + MySQL + Tailwind + highlight.js +
+
+
+ +
+ +
diff --git a/views/paste_view.php b/views/paste_view.php new file mode 100644 index 0000000..3416cec --- /dev/null +++ b/views/paste_view.php @@ -0,0 +1,187 @@ + + +
+
+

+
+ Language: +  •  + Created: +  •  + Views: +
+
+ +
+ Raw + +
+
+ + +
+
Delete token (store securely)
+
+ + +
+
This token is required to delete the paste if you don't use the same browser. It is shown once after creation.
+
+ + +
+
+
+ + +
+

Comments ()

+ + +
+ + + +
+ +
+ +
+
+ +
+ + +
Comments are anonymous; provide a name to display it.
+
+
+ + + +
No comments yet. Be the first to comment.
+ +
+ +
+
+
+
+
+
+
+ +
+ + +
+ + +
+

To delete this paste, enter the delete token (shown above at creation) or use the same browser (cookie-based). If you don't have either, contact the site admin or remove via DB.

+ +
+ + + + +
+
+ + diff --git a/views/sidebar.php b/views/sidebar.php new file mode 100644 index 0000000..dfc2821 --- /dev/null +++ b/views/sidebar.php @@ -0,0 +1,81 @@ + 0 ? (int) ceil($totalPastes / PASTES_PER_PAGE) : 1; +$sidebarPage = min($sidebarPage, $totalPages); +$sidebarPastes = fetch_pastes($pdo, $sidebarPage, PASTES_PER_PAGE); +?> + +