diff --git a/README.md b/README.md new file mode 100644 index 0000000..07c75b0 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Pastebin + +A lightweight, self-hosted Pastebin built with PHP and MySQL. No frameworks or Composer dependencies required. + +## Features + +- **Create / View / Delete** pastes with syntax highlighting +- **Anonymous comments** on individual paste pages +- **Syntax highlighting** via [highlight.js](https://highlightjs.org/) (CDN) +- **Responsive UI** with [Tailwind CSS](https://tailwindcss.com/) (CDN) +- **MySQL auto-setup** — database and tables are created automatically on first run +- **One-time delete token** shown after paste creation (session flash) with cookie fallback for the same browser +- **Raw endpoint** (`?raw=SLUG`) returns paste content as plain text + +## Directory Structure + +``` +pastebin/ +├── index.php # Entry point — session start + requires below files +├── src/ +│ ├── config.php # Database credentials and app constants +│ ├── db.php # PDO connection; auto-creates DB and tables +│ ├── helpers.php # Helper functions, language list, $basePath +│ └── handlers.php # POST/GET request handlers; sets view variables +├── views/ +│ ├── layout.php # HTML shell: head, header, main grid, footer, global JS +│ ├── paste.php # View-paste section: code, comments, delete form +│ ├── create.php # Create-paste form +│ └── sidebar.php # Recent pastes sidebar +├── LICENSE +└── README.md +``` + +## Requirements + +- PHP 7.4 or later (PHP 8.x recommended) +- MySQL 5.7+ or MariaDB 10.3+ +- A web server (Apache, Nginx, Caddy, …) with PHP support + +## Installation + +1. **Clone or download** the repository into your web server's document root (or a subdirectory). + +2. **Edit `src/config.php`** and set your database credentials: + + ```php + define('DB_HOST', '127.0.0.1'); + define('DB_PORT', '3306'); + define('DB_USER', 'your_db_user'); + define('DB_PASS', 'your_db_password'); + define('DB_NAME', 'pastebin_app'); + ``` + +3. **Visit the page** in your browser. The database and tables are created automatically on the first request. + +## Configuration + +All tuneable constants live in **`src/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 | +| `DB_NAME` | `pastebin_app` | Database name (auto-created) | +| `TABLE_PASTES` | `pastes` | Pastes table name | +| `TABLE_COMMENTS` | `comments` | Comments table name | +| `SLUG_LENGTH_BYTES` | `5` | Bytes used to generate paste slug (slug = hex, so length × 2 chars) | +| `DELETE_TOKEN_BYTES` | `12` | Bytes used to generate the delete token | +| `RECENT_COUNT` | `20` | Number of recent pastes shown in the sidebar | +| `COOKIE_LIFETIME` | `2592000` | Cookie lifetime in seconds (default: 30 days) | +| `COMMENT_MAX_LENGTH` | `2000` | Maximum comment length in characters | +| `COMMENT_NAME_MAX` | `100` | Maximum commenter name length in characters | + +## Usage + +### Creating a paste + +1. Open the homepage. +2. Optionally enter a **title** and select a **language** for syntax highlighting. +3. Paste your content and click **Create Paste**. +4. A **delete token** is displayed once — copy and store it if you want to delete the paste from a different browser. + +### Viewing a paste + +Paste URLs follow the pattern `/?view=`. The sidebar lists the 20 most recent pastes. + +### Raw content + +Append `?raw=` to fetch the paste content as plain text, useful for scripts: + +``` +curl https://your-host/?raw= +``` + +### Deleting a paste + +On the paste page, enter the delete token in the **Delete** form and submit. If you are on the same browser that created the paste, the token is pre-filled from a cookie. + +### Comments + +Each paste page has an anonymous comment form. A name is optional; if omitted, the comment is shown as *Anonymous*. + +## Production Checklist + +- [ ] Replace the default DB credentials in `src/config.php`. +- [ ] Serve the application over **HTTPS**. +- [ ] Build Tailwind CSS locally instead of using the CDN Play script. +- [ ] Set `secure` and `httponly` flags on cookies (`setcookie` calls in `src/handlers.php`). +- [ ] Consider adding rate limiting to the comment and create endpoints. +- [ ] Back up your MySQL database regularly. + +## License + +See [LICENSE](LICENSE). diff --git a/index.php b/index.php index 9bab250..352e93c 100644 --- a/index.php +++ b/index.php @@ -1,704 +1,23 @@ 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; - } -} - -/* 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) */ -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; -} - -/* View paste (if ?view=SLUG) - fetch paste and its comments */ -$viewSlug = isset($_GET['view']) ? $_GET['view'] : 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->execute([':s' => $viewSlug]); - $paste = $stmt->fetch(); - - if ($paste) { - // increment views - $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"); - $cstmt->execute([':pid' => $paste['id']]); - $comments = $cstmt->fetchAll(); - } else { - $viewSlug = null; // not found - } -} - -/* 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.
-
- - -
-
-
- - -
-

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.

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

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.
-
-
- - -
- - - -
- -
- Single-file Pastebin demo with comments. For production: secure DB credentials, enable HTTPS, and build Tailwind. -
-
-
- - - - - +require __DIR__ . '/views/layout.php'; // render full HTML page diff --git a/src/config.php b/src/config.php new file mode 100644 index 0000000..bec19e7 --- /dev/null +++ b/src/config.php @@ -0,0 +1,21 @@ + 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 + $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; + "); + + // Create comments table if not exists (anonymous comments) + $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; + "); + +} catch (PDOException $e) { + http_response_code(500); + echo "

Database error

" . htmlspecialchars($e->getMessage()) . "
"; + exit; +} diff --git a/src/handlers.php b/src/handlers.php new file mode 100644 index 0000000..6f89125 --- /dev/null +++ b/src/handlers.php @@ -0,0 +1,211 @@ +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 and cookie so the token survives the redirect + $_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 (POST action=delete) */ +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 !== '' && hash_equals($stored_token, $provided_token)) { + $allowed = true; + } + 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; + } +} + +/* 3) Add anonymous comment (POST action=add_comment) */ +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 { + $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, + ]); + + // Persist commenter name in cookie to pre-fill on next visit + 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; + } +} + +/* 4) Raw endpoint: ?raw=SLUG (returns paste as plain text) */ +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 view counter + $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; +} + +/* 5) View paste: ?view=SLUG */ +$viewSlug = isset($_GET['view']) ? $_GET['view'] : 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->execute([':s' => $viewSlug]); + $paste = $stmt->fetch(); + + if ($paste) { + // 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" + ); + $cstmt->execute([':pid' => $paste['id']]); + $comments = $cstmt->fetchAll(); + } else { + $viewSlug = null; // paste not found + } +} + +/* Recent pastes for the sidebar */ +$recentPastes = fetch_recent($pdo); diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..1eeb51d --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,68 @@ +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 cryptographically random delete token (hex). */ +function generate_delete_token(): string +{ + return bin2hex(random_bytes(DELETE_TOKEN_BYTES)); +} + +/** Fetch the most recent pastes for the sidebar. */ +function fetch_recent(PDO $pdo, int $count = RECENT_COUNT): array +{ + $stmt = $pdo->prepare( + "SELECT slug, title, language, created_at + FROM `" . TABLE_PASTES . "` + ORDER BY created_at DESC + LIMIT :cnt" + ); + $stmt->bindValue(':cnt', $count, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); +} + +/* =========================== + SHARED VIEW DATA + =========================== */ + +/** Supported language options for the syntax-highlight selector. */ +$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 stripped of query string (used for redirect/link URLs). */ +$basePath = strtok($_SERVER['REQUEST_URI'], '?'); diff --git a/views/create.php b/views/create.php new file mode 100644 index 0000000..803be2b --- /dev/null +++ b/views/create.php @@ -0,0 +1,63 @@ + when no paste is being viewed. + * Expects: $languages (set by src/helpers.php). + */ +?> +

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. +
+
+
diff --git a/views/layout.php b/views/layout.php new file mode 100644 index 0000000..efe0075 --- /dev/null +++ b/views/layout.php @@ -0,0 +1,121 @@ +, , header, main grid (section + aside), footer, global JS. + * Includes views/paste.php or views/create.php depending on $viewSlug/$paste, + * and always includes views/sidebar.php. + */ +?> + + + + + Pastebin + + + + + + + + + + + + + + + +
+
+ + +
+
+
+

Pastebin

+

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

+
+
+ PHP + MySQL + Tailwind + highlight.js +
+
+
+ +
+ + +
+ + + + + +
+ + + + +
+ +
+ Pastebin demo with comments. + For production: secure DB credentials, enable HTTPS, and build Tailwind locally. +
+ +
+
+ + + + + diff --git a/views/paste.php b/views/paste.php new file mode 100644 index 0000000..f225342 --- /dev/null +++ b/views/paste.php @@ -0,0 +1,212 @@ + when a paste is found ($viewSlug && $paste). + * Expects: $paste, $comments, $basePath (all set by src/handlers.php). + */ + +// One-time token display: session flash first, then cookie fallback +$show_token = null; +if ( + isset($_SESSION['last_paste']) && + is_array($_SESSION['last_paste']) && + $_SESSION['last_paste']['slug'] === $paste['slug'] +) { + $show_token = $_SESSION['last_paste']['delete_token']; + unset($_SESSION['last_paste']); +} else { + $cookieName = 'paste_token_' . $paste['slug']; + if (isset($_COOKIE[$cookieName]) && is_string($_COOKIE[$cookieName]) && $_COOKIE[$cookieName] !== '') { + $show_token = $_COOKIE[$cookieName]; + } +} + +// Pre-fill commenter name from cookie +$prefill_commenter = isset($_COOKIE['commenter_name']) ? $_COOKIE['commenter_name'] : ''; +?> + +
+
+

+
+ 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..c6c139d --- /dev/null +++ b/views/sidebar.php @@ -0,0 +1,39 @@ +. Expects: $recentPastes, $basePath. + */ +?> +
+

Recent pastes

+ +
No pastes yet.
+ +
    + +
  • +
    + + + +
    + +  ·  + +
    +
    +
  • + +
+ +
+ +
+ Tips: +
    +
  • Store the delete token shown immediately to delete the paste from another device.
  • +
  • Cookie fallback allows deletion from the same browser without manual token copy.
  • +
  • Comments are anonymous; name is optional. Provide a name to display it.
  • +
+