diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1735dd7 Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env new file mode 100644 index 0000000..714b528 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +MASTER_KEY=2c19e5c2-eb30-410b-807e-411643d5d730 +DB_HOST=localhost +DB_NAME=paste +DB_USER=root +DB_PASS=sH1382 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0700f5e --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +MASTER_KEY=change_me_to_a_random_secret +DB_HOST=localhost +DB_NAME=paste +DB_USER=root +DB_PASS= +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +APP_URL= diff --git a/.gitignore b/.gitignore index 2eea525..6c23564 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -.env \ No newline at end of file +.env.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..037b3c5 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# SafePaste + +A minimal, encrypted pastebin built with PHP, MySQL, and Redis. + +## Features + +- **AES-256-CBC encryption** — all pastes are encrypted at rest using a master key +- **Optional password protection** — bcrypt-hashed passwords per paste +- **TTL expiry via Redis** — short-lived pastes live only in Redis; permanent pastes use MySQL +- **Clean dark UI** — responsive, accessible, no external dependencies + +## Project Structure + +``` +safe-paste/ +├── app/ +│ ├── config/config.php # Loads .env settings +│ ├── core/ +│ │ ├── db.php # PDO connection +│ │ ├── redis.php # Redis singleton +│ │ └── security.php # Encrypt/decrypt helpers +│ ├── controllers/ +│ │ ├── SaveController.php # POST handler: create paste +│ │ └── ViewController.php # GET/POST handler: view paste +│ └── models/Paste.php # Save/get paste (Redis + MySQL) +└── public/ + ├── index.php # Home page + save action + ├── view.php # View paste page + ├── error.php # Error page + ├── .htaccess # URL routing + └── assets/ + ├── css/style.css + └── js/app.js +``` + +## Setup + +1. Copy `.env.example` to `.env` and fill in your values +2. Create the MySQL database and table: + +```sql +CREATE DATABASE paste CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE paste; +CREATE TABLE pastes ( + id CHAR(32) PRIMARY KEY, + encrypted_text TEXT NOT NULL, + iv VARCHAR(64) NOT NULL, + expire_time INT DEFAULT NULL, + password_hash VARCHAR(255) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +3. Point your web server document root to `public/` +4. Ensure `mod_rewrite` is enabled (Apache) or configure your nginx equivalent + +## .env + +```ini +MASTER_KEY=your-random-secret-key +DB_HOST=localhost +DB_NAME=paste +DB_USER=root +DB_PASS=secret +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +APP_URL=https://yourdomain.com +``` diff --git a/app/config/config.php b/app/config/config.php index fd93941..20f0d84 100644 --- a/app/config/config.php +++ b/app/config/config.php @@ -1,15 +1,22 @@ [ - 'host' => $env['DB_HOST'] ?? 'localhost', - 'name' => $env['DB_NAME'] ?? 'paste', - 'user' => $env['DB_USER'] ?? 'root', - 'pass' => $env['DB_PASS'] ?? '' + 'host' => $env['DB_HOST'] ?? 'localhost', + 'name' => $env['DB_NAME'] ?? 'paste', + 'user' => $env['DB_USER'] ?? 'root', + 'pass' => $env['DB_PASS'] ?? '', + 'charset' => 'utf8mb4', ], 'redis' => [ - 'host' => '127.0.0.1', - 'port' => 6379 + 'host' => $env['REDIS_HOST'] ?? '127.0.0.1', + 'port' => (int)($env['REDIS_PORT'] ?? 6379), + ], + 'app' => [ + 'master_key' => $env['MASTER_KEY'] ?? 'change_me_master_key', + 'base_url' => $env['APP_URL'] ?? '', + 'cipher' => 'AES-256-CBC', ], - 'master_key' => $env['MASTER_KEY'] ?? 'change_me_master_key' ]; diff --git a/app/controllers/SaveController.php b/app/controllers/SaveController.php index b895ce4..3330479 100644 --- a/app/controllers/SaveController.php +++ b/app/controllers/SaveController.php @@ -1,31 +1,31 @@ false, 'message' => 'Text cannot be empty.'], 422); } -$id = generateId(); -$enc = encryptText($text, $config['master_key']); + +$id = generateId(); +$enc = encryptText($text, $config['app']['master_key']); $password_hash = $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : null; -$expire_time = $expire > 0 ? time() + $expire : null; +$expire_time = $expire > 0 ? time() + $expire : null; + $paste = new Paste($pdo); +$paste->save($id, $enc['cipher'], $enc['iv'], $expire_time, $password_hash); -$paste->save($id, $enc['cipher'], $enc['iv'], $expire_time,$password_hash); +$base = rtrim($config['app']['base_url'] ?: ('http' . (isset($_SERVER['HTTPS']) ? 's' : '') . '://' . $_SERVER['HTTP_HOST']), '/'); +$url = $base . '/view/' . $id; - -$url = "http://" . $_SERVER['HTTP_HOST'] . "/view.php?id=" . $id; - -echo json_encode([ - "success" => true, - "url" => $url -]); -exit; \ No newline at end of file +jsonResponse(['success' => true, 'url' => $url]); diff --git a/app/controllers/ViewController.php b/app/controllers/ViewController.php index 7128c82..bfa3f76 100644 --- a/app/controllers/ViewController.php +++ b/app/controllers/ViewController.php @@ -1,59 +1,56 @@ get($id); +$data = $paste->get($id); if (!$data) { - die('Paste not found.'); + $errorCode = 404; + $errorMessage = 'Paste not found.'; + require __DIR__ . '/../../public/error.php'; + exit; } if ($data['expire_time'] !== null && time() > (int)$data['expire_time']) { - die('Paste has expired.'); + $errorCode = 410; + $errorMessage = 'This paste has expired.'; + require __DIR__ . '/../../public/error.php'; + exit; } -if ($data['password_hash']) { - if (!isset($_POST['password'])) { - echo ""; - echo "
"; - echo ""; - echo ""; - echo "
"; +$needsPassword = (bool)$data['password_hash']; +$wrongPassword = false; +$decrypted = null; + +if ($needsPassword) { + $submitted = $_POST['password'] ?? null; + if ($submitted !== null) { + if (password_verify($submitted, $data['password_hash'])) { + $needsPassword = false; + } else { + $wrongPassword = true; + } + } +} + +if (!$needsPassword) { + $decrypted = decryptText($data['encrypted_text'], $data['iv'], $config['app']['master_key']); + if ($decrypted === false) { + $errorCode = 500; + $errorMessage = 'Decryption failed. The paste may be corrupted.'; + require __DIR__ . '/../../public/error.php'; exit; } - - if (!password_verify($_POST['password'], $data['password_hash'])) { - die('Wrong password.'); - } } - -$decrypted = decryptText($data['encrypted_text'], $data['iv'], $config['master_key']); - -if ($decrypted === false) { - die('Decryption failed.'); -} -?> - - - - - - View Paste - - - - -
-

Your Paste

-
- -
-
-
- - \ No newline at end of file diff --git a/app/core/db.php b/app/core/db.php index 7fc0cd1..4a41208 100644 --- a/app/core/db.php +++ b/app/core/db.php @@ -1,13 +1,21 @@ PDO::ERRMODE_EXCEPTION] + $dsn = sprintf( + 'mysql:host=%s;dbname=%s;charset=%s', + $config['db']['host'], + $config['db']['name'], + $config['db']['charset'] ); -} catch (Exception $e) { - die('Database connection error'); + $pdo = new PDO($dsn, $config['db']['user'], $config['db']['pass'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); +} catch (PDOException $e) { + http_response_code(503); + die(json_encode(['success' => false, 'message' => 'Database connection error.'])); } + return $pdo; diff --git a/app/core/redis.php b/app/core/redis.php index 613cb18..f848d93 100644 --- a/app/core/redis.php +++ b/app/core/redis.php @@ -1,13 +1,14 @@ connect($config['redis']['host'], $config['redis']['port']); + $redis = new Redis(); + if (!@$redis->connect($config['redis']['host'], $config['redis']['port'])) { + throw new RuntimeException('Redis connection failed.'); + } } return $redis; } diff --git a/app/core/security.php b/app/core/security.php index f3c6238..4ea3b50 100644 --- a/app/core/security.php +++ b/app/core/security.php @@ -1,15 +1,32 @@ $cipher, - 'iv' => base64_encode($iv) + 'iv' => base64_encode($iv), ]; } -function decryptText($cipher, $iv, $key) { + +function decryptText(string $cipher, string $iv, string $key): string|false +{ return openssl_decrypt($cipher, 'AES-256-CBC', $key, 0, base64_decode($iv)); } -function generateId() { + +function generateId(): string +{ return bin2hex(random_bytes(16)); } + +function jsonResponse(array $data, int $status = 200): void +{ + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($data); + exit; +} diff --git a/app/models/Paste.php b/app/models/Paste.php index 01e4e50..ecf512b 100644 --- a/app/models/Paste.php +++ b/app/models/Paste.php @@ -1,62 +1,58 @@ pdo = $pdo; } - public function save($id, $encrypted_text, $iv, $expire_time, $password_hash) - { - if ($expire_time === null) { - $stmt = $this->pdo->prepare("INSERT INTO pastes(id, encrypted_text, iv, expire_time, password_hash) - VALUES (?, ?, ?, NULL, ?)"); - return $stmt->execute([$id, $encrypted_text, $iv, $password_hash]); + public function save( + string $id, + string $encrypted_text, + string $iv, + ?int $expire_time, + ?string $password_hash + ): bool { + if ($expire_time !== null) { + $ttl = max(1, $expire_time - time()); + $redis = redisClient(); + $redis->setex("paste:{$id}", $ttl, json_encode([ + 'encrypted_text' => $encrypted_text, + 'iv' => $iv, + 'password_hash' => $password_hash, + ])); + return true; } - $redis = redisClient(); - - $expire_time = (int)$expire_time; - $ttl = max(1, $expire_time - time()); - - - $redis->setex( - "paste:$id", - $ttl, - json_encode([ - 'encrypted_text' => $encrypted_text, - 'iv' => $iv, - 'password_hash' => $password_hash - ]) + $stmt = $this->pdo->prepare( + 'INSERT INTO pastes (id, encrypted_text, iv, expire_time, password_hash) + VALUES (?, ?, ?, NULL, ?)' ); - - - return true; + return $stmt->execute([$id, $encrypted_text, $iv, $password_hash]); } - - public function get($id) + public function get(string $id): array|false { $redis = redisClient(); + $raw = $redis->get("paste:{$id}"); - $data = $redis->get("paste:$id"); - if ($data !== false) { - $json = json_decode($data, true); + if ($raw !== false) { + $json = json_decode($raw, true); return [ 'encrypted_text' => $json['encrypted_text'], - 'iv' => $json['iv'], - 'password_hash' => $json['password_hash'], - 'expire_time' => time() + $redis->ttl("paste:$id") + 'iv' => $json['iv'], + 'password_hash' => $json['password_hash'], + 'expire_time' => time() + $redis->ttl("paste:{$id}"), ]; } - $stmt = $this->pdo->prepare("SELECT * FROM pastes WHERE id = ?"); + $stmt = $this->pdo->prepare('SELECT * FROM pastes WHERE id = ? LIMIT 1'); $stmt->execute([$id]); - return $stmt->fetch(PDO::FETCH_ASSOC); + return $stmt->fetch(); } } diff --git a/public/.htaccess b/public/.htaccess index e69de29..f678b92 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -0,0 +1,12 @@ +Options -Indexes +DirectoryIndex index.php + +RewriteEngine On + +# Route /view/{id} -> view.php?id={id} (GET and POST) +RewriteCond %{REQUEST_METHOD} GET [OR] +RewriteCond %{REQUEST_METHOD} POST +RewriteRule ^view/([a-f0-9]+)/?$ view.php?id=$1 [L,QSA] + +# Block direct access to .env and sensitive files +RewriteRule ^(\.env|\.git) - [F,L] diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 695dd8e..2842f22 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1,83 +1,512 @@ -* { +/* ─── Reset & Base ─── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; - font-family: monospace; } +:root { + --bg: #080e1a; + --surface: #0d1526; + --surface-2: #111d33; + --border: #1e2f4a; + --border-soft: #172137; + --accent: #3b9eff; + --accent-glow: rgba(59,158,255,0.18); + --accent-dark: #1a6fd4; + --text: #dce8f8; + --text-muted: #5f7da0; + --text-dim: #3a5272; + --success: #22c55e; + --error: #f43f5e; + --error-bg: rgba(244,63,94,0.1); + --radius: 12px; + --radius-sm: 8px; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + --font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; + --shadow: 0 4px 24px rgba(0,0,0,0.4); + --shadow-lg: 0 8px 48px rgba(0,0,0,0.6); +} + +html { font-size: 15px; } + body { - background: #0f172a; - color: #e2e8f0; - padding: 40px; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + min-height: 100vh; + line-height: 1.6; + -webkit-font-smoothing: antialiased; } -/* container */ -.container { - max-width: 900px; - margin: auto; +/* ─── Page Layout ─── */ +.page-wrapper { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 760px; + margin: 0 auto; + padding: 0 1.25rem; } -/* title */ -h1 { - margin-bottom: 20px; - font-size: 28px; +/* ─── Header ─── */ +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.4rem 0; + border-bottom: 1px solid var(--border-soft); + margin-bottom: 2.5rem; } -/* textarea */ -textarea { - width: 100%; - min-height: 300px; - background: #020617; - border: 1px solid #334155; - color: #e2e8f0; - padding: 12px; - resize: vertical; - font-size: 14px; - border-radius: 6px; -} - -.usepassword, -select { - background: #020617; - border: 1px solid #334155; - color: #e2e8f0; - padding: 8px; - border-radius: 6px; - margin-top: 10px; -} - -/* button */ -button { - background: #38bdf8; - border: none; - color: #020617; - padding: 10px 16px; - margin-top: 15px; - border-radius: 6px; - cursor: pointer; - font-weight: bold; -} - -button:hover { - background: #0ea5e9; -} - -/* paste display */ -.paste-box { - background: #020617; - border: 1px solid #334155; - padding: 15px; - margin-top: 20px; - border-radius: 6px; - overflow-x: auto; -} - -/* link */ -a { - color: #38bdf8; +.logo { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + font-size: 1.05rem; + letter-spacing: -0.01em; + color: var(--text); text-decoration: none; } -a:hover { - text-decoration: underline; +.logo svg { color: var(--accent); } + +.header-badge { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--accent); + background: var(--accent-glow); + border: 1px solid rgba(59,158,255,0.25); + padding: 0.28rem 0.65rem; + border-radius: 999px; +} + +.btn-new-paste { + font-size: 0.82rem; + font-weight: 600; + color: var(--text); + background: var(--surface-2); + border: 1px solid var(--border); + padding: 0.45rem 1rem; + border-radius: var(--radius-sm); + text-decoration: none; + transition: border-color 0.18s, background 0.18s; +} + +.btn-new-paste:hover { + border-color: var(--accent); + background: var(--accent-glow); + text-decoration: none; +} + +/* ─── Main Card ─── */ +.main-card { + flex: 1; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem 2.25rem; + box-shadow: var(--shadow); +} + +.card-header { + margin-bottom: 1.75rem; +} + +.card-header h1 { + font-size: 1.45rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); + margin-bottom: 0.3rem; +} + +.card-header p { + font-size: 0.875rem; + color: var(--text-muted); +} + +/* ─── Form Fields ─── */ +.field { margin-bottom: 1rem; } + +.textarea-wrapper { position: relative; } + +textarea { + width: 100%; + min-height: 280px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.7; + padding: 1rem 1.1rem 2.5rem; + resize: vertical; + outline: none; + transition: border-color 0.18s, box-shadow 0.18s; + tab-size: 4; +} + +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +textarea::placeholder { color: var(--text-dim); } + +.char-counter { + position: absolute; + bottom: 0.6rem; + right: 0.85rem; + font-size: 0.72rem; + color: var(--text-dim); + font-family: var(--font-mono); + pointer-events: none; +} + +/* ─── Options Row ─── */ +.options-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.field-group { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.field-group label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +select, +input[type="password"], +input[type="text"] { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font-sans); + font-size: 0.875rem; + padding: 0.6rem 0.85rem; + outline: none; + transition: border-color 0.18s, box-shadow 0.18s; + appearance: none; + -webkit-appearance: none; +} + +select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235f7da0' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.2rem; +} + +select:focus, +input[type="password"]:focus, +input[type="text"]:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +select option { background: var(--surface-2); } + +/* ─── Buttons ─── */ +.btn-primary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius-sm); + padding: 0.7rem 1.4rem; + font-family: var(--font-sans); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background 0.18s, transform 0.1s, box-shadow 0.18s; +} + +.btn-primary:hover { + background: var(--accent-dark); + box-shadow: 0 0 20px var(--accent-glow); + text-decoration: none; +} + +.btn-primary:active { transform: scale(0.98); } + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-icon { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: var(--surface-2); + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.4rem 0.85rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: color 0.18s, border-color 0.18s, background 0.18s; +} + +.btn-icon:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-glow); +} + +/* ─── Result Box ─── */ +.result-box { + margin-top: 1.5rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-left: 3px solid var(--success); + border-radius: var(--radius-sm); + padding: 1.25rem 1.4rem; +} + +.result-box[hidden] { display: none; } + +.result-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + font-weight: 600; + color: var(--success); + margin-bottom: 1rem; +} + +.result-link-row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.85rem; +} + +.result-link-row input[type="text"] { + flex: 1; + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-muted); + cursor: text; +} + +.btn-copy { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 1rem; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.18s, border-color 0.18s, color 0.18s; +} + +.btn-copy:hover { + border-color: var(--accent); + color: var(--accent); +} + +.btn-copy.copied { + color: var(--success); + border-color: var(--success); +} + +.result-open-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--text-muted); + text-decoration: none; + transition: color 0.18s; +} + +.result-open-link:hover { + color: var(--accent); + text-decoration: none; +} + +/* ─── Paste View ─── */ +.paste-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +.meta-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + background: var(--surface-2); + border: 1px solid var(--border); + padding: 0.28rem 0.65rem; + border-radius: 999px; +} + +.paste-box { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.paste-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.85rem; + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} + +.paste-toolbar-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); +} + +#pasteContent { + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.75; + color: var(--text); + padding: 1.25rem 1.4rem; + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + margin: 0; +} + +/* ─── Password Prompt ─── */ +.lock-icon { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + background: var(--accent-glow); + border: 1px solid rgba(59,158,255,0.3); + border-radius: 14px; + margin-bottom: 1.25rem; + color: var(--accent); +} + +.password-form { max-width: 380px; } + +.input-password { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 0.9rem; + padding: 0.7rem 0.95rem; + outline: none; + margin-bottom: 1rem; + transition: border-color 0.18s, box-shadow 0.18s; +} + +.input-password:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +/* ─── Alerts ─── */ +.alert { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 1rem; +} + +.alert-error { + background: var(--error-bg); + border: 1px solid rgba(244,63,94,0.3); + color: var(--error); +} + +/* ─── Error Page ─── */ +.error-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 4rem 2rem; + min-height: 320px; +} + +.error-code { + font-size: 5rem; + font-weight: 800; + letter-spacing: -0.04em; + color: var(--border); + line-height: 1; + margin-bottom: 0.75rem; +} + +.error-message { + font-size: 1rem; + color: var(--text-muted); + max-width: 340px; +} + +/* ─── Footer ─── */ +.site-footer { + padding: 1.5rem 0; + margin-top: 2rem; + text-align: center; + font-size: 0.75rem; + color: var(--text-dim); + border-top: 1px solid var(--border-soft); +} + +/* ─── Submit button loading state ─── */ +.btn-primary.loading { opacity: 0.7; pointer-events: none; } + +/* ─── Responsive ─── */ +@media (max-width: 560px) { + .main-card { padding: 1.5rem 1.25rem; } + .options-row { grid-template-columns: 1fr; } + .result-link-row { flex-direction: column; } + .btn-copy { width: 100%; justify-content: center; } } diff --git a/public/assets/js/app.js b/public/assets/js/app.js index fb1c30f..af98321 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -1,38 +1,106 @@ -document.addEventListener("DOMContentLoaded", () => { +document.addEventListener('DOMContentLoaded', () => { - const form = document.getElementById("pasteForm"); - const resultBox = document.getElementById("result"); + /* ── Create-paste page ── */ + const pasteForm = document.getElementById('pasteForm'); + const resultBox = document.getElementById('result'); + const pasteText = document.getElementById('pasteText'); + const charCount = document.getElementById('charCount'); + const submitBtn = document.getElementById('submitBtn'); - form.addEventListener("submit", async (e) => { - e.preventDefault(); + if (pasteText && charCount) { + const update = () => { + charCount.textContent = pasteText.value.length.toLocaleString(); + pasteText.style.height = 'auto'; + pasteText.style.height = Math.max(280, pasteText.scrollHeight) + 'px'; + }; + pasteText.addEventListener('input', update); + update(); + } - const formData = new FormData(form); + if (pasteForm) { + pasteForm.addEventListener('submit', async (e) => { + e.preventDefault(); - const response = await fetch("/index.php?action=save", { - method: "POST", - body: formData + if (!pasteText || pasteText.value.trim() === '') { + pasteText.focus(); + pasteText.style.borderColor = 'var(--error)'; + setTimeout(() => pasteText.style.borderColor = '', 1200); + return; + } + + submitBtn.classList.add('loading'); + submitBtn.querySelector('span').textContent = 'Creating...'; + + try { + const res = await fetch('/index.php?action=save', { + method: 'POST', + body: new FormData(pasteForm), + }); + const data = await res.json(); + + if (!data.success) { + showError(data.message || 'Something went wrong.'); + return; + } + + const linkInput = document.getElementById('pasteLink'); + const viewLink = document.getElementById('viewLink'); + linkInput.value = data.url; + viewLink.href = data.url; + resultBox.removeAttribute('hidden'); + resultBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + pasteText.value = ''; + if (charCount) charCount.textContent = '0'; + + } catch { + showError('Network error. Please try again.'); + } finally { + submitBtn.classList.remove('loading'); + submitBtn.querySelector('span').textContent = 'Create Secure Link'; + } }); - const data = await response.json(); - - if (!data.success) { - resultBox.innerHTML = "
" + data.message + "
"; - return; + function showError(msg) { + resultBox.removeAttribute('hidden'); + resultBox.style.borderLeftColor = 'var(--error)'; + resultBox.innerHTML = ` +
+ + ${msg} +
`; } + } - resultBox.innerHTML = ` -

Paste Created

- - - - - `; - - document.getElementById("copyBtn").addEventListener("click", () => { - const input = document.getElementById("pasteLink"); - navigator.clipboard.writeText(input.value); - alert("Link copied!"); - }); + /* ── Copy link button (result box) ── */ + document.addEventListener('click', (e) => { + const btn = e.target.closest('#copyLinkBtn'); + if (!btn) return; + const val = document.getElementById('pasteLink')?.value; + if (!val) return; + copyToClipboard(val, btn, 'Copied!', 'Copy'); }); + /* ── View-paste page: copy content ── */ + const copyBtn = document.getElementById('copyBtn'); + if (copyBtn) { + copyBtn.addEventListener('click', () => { + const content = document.getElementById('pasteContent')?.innerText; + if (!content) return; + copyToClipboard(content, copyBtn, 'Copied!', 'Copy'); + }); + } + + /* ── Shared helper ── */ + function copyToClipboard(text, btn, successLabel, defaultLabel) { + navigator.clipboard.writeText(text).then(() => { + const span = btn.querySelector('span') || btn; + btn.classList.add('copied'); + span.textContent = successLabel; + setTimeout(() => { + btn.classList.remove('copied'); + span.textContent = defaultLabel; + }, 2000); + }); + } }); diff --git a/public/assets/js/main.js b/public/assets/js/main.js index 6b9953d..598c59f 100644 --- a/public/assets/js/main.js +++ b/public/assets/js/main.js @@ -1,40 +1 @@ -document.addEventListener("DOMContentLoaded", () => { - - const textarea = document.querySelector("textarea"); - const form = document.querySelector("form"); - const copyBtn = document.getElementById("copyBtn"); - - - if (textarea) { - textarea.addEventListener("input", () => { - textarea.style.height = "auto"; - textarea.style.height = textarea.scrollHeight + "px"; - }); - } - - if (form && textarea) { - form.addEventListener("submit", (e) => { - if (textarea.value.trim() === "") { - e.preventDefault(); - alert("Paste cannot be empty"); - } - }); - } - - if (copyBtn) { - copyBtn.addEventListener("click", () => { - const paste = document.getElementById("pasteContent"); - - if (!paste) return; - - navigator.clipboard.writeText(paste.innerText) - .then(() => { - copyBtn.textContent = "Copied!"; - setTimeout(() => { - copyBtn.textContent = "Copy"; - }, 2000); - }); - }); - } - -}); +/* merged into app.js */ diff --git a/public/error.php b/public/error.php new file mode 100644 index 0000000..daaabd0 --- /dev/null +++ b/public/error.php @@ -0,0 +1,35 @@ + + + + + + + SafePaste — <?= $errorCode ?> + + + +
+ +
+
+

+ + + Back to Home + +
+ +
+ + diff --git a/public/index.php b/public/index.php index 7dad121..7694c57 100644 --- a/public/index.php +++ b/public/index.php @@ -1,28 +1,108 @@ - - - + + + + + + SafePaste — Secure Text Sharing + + + +
+ +
+
+

New Paste

+

Your text is encrypted before storage. Share safely.

+
-
- - - - -
+
+
+
+ +
0 chars
+
+
-
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + + diff --git a/public/router.php b/public/router.php new file mode 100644 index 0000000..7fc9ee5 --- /dev/null +++ b/public/router.php @@ -0,0 +1,21 @@ + + + + + + + SafePaste — View + + + +
+ -require __DIR__ . '/../app/controllers/ViewController.php'; +
+ -?> +
+
+ +
+

Password Required

+

This paste is protected. Enter the password to view it.

+
+ +
+ +
+ + Incorrect password. Please try again. +
+ +
+ +
+ +
+ + + +
+ + + + Expires + + + + + No expiry + + + + + chars + +
+ +
+
+ Content + +
+
+
+ + +
+ + +
+ + + +