Update -> refactor and optimize UI , code ,...

This commit is contained in:
2026-04-10 11:35:25 +03:30
parent 3327207f05
commit 0de951fd91
20 changed files with 1085 additions and 282 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

5
.env Normal file
View File

@@ -0,0 +1,5 @@
MASTER_KEY=2c19e5c2-eb30-410b-807e-411643d5d730
DB_HOST=localhost
DB_NAME=paste
DB_USER=root
DB_PASS=sH1382

8
.env.example Normal file
View File

@@ -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=

2
.gitignore vendored
View File

@@ -1 +1 @@
.env
.env.DS_Store

68
README.md Normal file
View File

@@ -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
```

View File

@@ -1,15 +1,22 @@
<?php
$env = parse_ini_file(__DIR__ . '/../../.env');
$envFile = __DIR__ . '/../../.env';
$env = file_exists($envFile) ? parse_ini_file($envFile) : [];
return [
'db' => [
'host' => $env['DB_HOST'] ?? 'localhost',
'name' => $env['DB_NAME'] ?? 'paste',
'user' => $env['DB_USER'] ?? 'root',
'pass' => $env['DB_PASS'] ?? ''
'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'
];

View File

@@ -1,31 +1,31 @@
<?php
require __DIR__ . '/../core/security.php';
require_once __DIR__ . '/../core/security.php';
$pdo = require __DIR__ . '/../core/db.php';
require __DIR__ . '/../models/Paste.php';
require_once __DIR__ . '/../models/Paste.php';
$config = require __DIR__ . '/../config/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /index.php');
header('Location: /');
exit;
}
$text = $_POST['text'] ?? '';
$text = trim($_POST['text'] ?? '');
$password = $_POST['password'] ?? '';
$expire = isset($_POST['expire']) ? intval($_POST['expire']) : 0;
if (trim($text) === '') {
die('Text is required');
$expire = isset($_POST['expire']) ? (int)$_POST['expire'] : 0;
if ($text === '') {
jsonResponse(['success' => false, 'message' => 'Text cannot be empty.'], 422);
}
$id = generateId();
$enc = encryptText($text, $config['master_key']);
$enc = encryptText($text, $config['app']['master_key']);
$password_hash = $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : 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;
jsonResponse(['success' => true, 'url' => $url]);

View File

@@ -1,59 +1,56 @@
<?php
require __DIR__ . '/../core/security.php';
require_once __DIR__ . '/../core/security.php';
$pdo = require __DIR__ . '/../core/db.php';
require __DIR__ . '/../models/Paste.php';
require_once __DIR__ . '/../models/Paste.php';
$config = require __DIR__ . '/../config/config.php';
$id = $_GET['id'] ?? '';
$id = preg_replace('/[^a-f0-9]/i', '', $_GET['id'] ?? '');
if ($id === '') {
$errorCode = 404;
$errorMessage = 'Invalid paste ID.';
require __DIR__ . '/../../public/error.php';
exit;
}
$paste = new Paste($pdo);
$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 "<link rel='stylesheet' href='/assets/css/style.css'>";
echo "<form method='post'>";
echo "<input type='password' class='usepassword' name='password' placeholder='Password'>";
echo "<button type='submit'>View</button>";
echo "</form>";
$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.');
}
?>
<!DOCTYPE html>
<html>
<head>
<title>View Paste</title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<div class="container">
<h1>Your Paste</h1>
<div class="paste-box">
<button id="copyBtn">Copy</button>
<pre id="pasteContent"><?= htmlspecialchars($decrypted) ?></pre>
</div>
</div>
</body>
</html>

View File

@@ -1,13 +1,21 @@
<?php
$config = require __DIR__ . '/../config/config.php';
try {
$pdo = new PDO(
"mysql:host={$config['db']['host']};dbname={$config['db']['name']};charset=utf8mb4",
$config['db']['user'],
$config['db']['pass'],
[PDO::ATTR_ERRMODE => 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;

View File

@@ -1,13 +1,14 @@
<?php
function redisClient()
function redisClient(): Redis
{
static $redis = null;
if ($redis === null) {
$config = require __DIR__ . '/../config/config.php';
$redis = new Redis();
$redis->connect($config['redis']['host'], $config['redis']['port']);
if (!@$redis->connect($config['redis']['host'], $config['redis']['port'])) {
throw new RuntimeException('Redis connection failed.');
}
}
return $redis;
}

View File

@@ -1,15 +1,32 @@
<?php
function encryptText($text, $key) {
function encryptText(string $text, string $key): array
{
$iv = random_bytes(16);
$cipher = openssl_encrypt($text, 'AES-256-CBC', $key, 0, $iv);
if ($cipher === false) {
throw new RuntimeException('Encryption failed.');
}
return [
'cipher' => $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;
}

View File

@@ -1,62 +1,58 @@
<?php
require_once __DIR__ . '/../core/db.php';
require_once __DIR__ . '/../core/redis.php';
require_once __DIR__ . '/../core/redis.php';
class Paste
{
private $pdo;
public function __construct($pdo)
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->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]);
}
$redis = redisClient();
$expire_time = (int)$expire_time;
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->setex(
"paste:$id",
$ttl,
json_encode([
$redis = redisClient();
$redis->setex("paste:{$id}", $ttl, json_encode([
'encrypted_text' => $encrypted_text,
'iv' => $iv,
'password_hash' => $password_hash
])
);
'password_hash' => $password_hash,
]));
return true;
}
$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 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")
'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();
}
}

View File

@@ -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]

View File

@@ -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; }
}

View File

@@ -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) => {
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();
}
if (pasteForm) {
pasteForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const response = await fetch("/index.php?action=save", {
method: "POST",
body: formData
});
const data = await response.json();
if (!data.success) {
resultBox.innerHTML = "<div class='error'>" + data.message + "</div>";
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';
}
});
function showError(msg) {
resultBox.removeAttribute('hidden');
resultBox.style.borderLeftColor = 'var(--error)';
resultBox.innerHTML = `
<h3>Paste Created</h3>
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
${msg}
</div>`;
}
}
<input type="text" id="pasteLink" value="${data.url}" readonly style="width:100%; padding:8px;">
<button id="copyBtn">Copy Link</button>
`;
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);
});
}
});

View File

@@ -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 */

35
public/error.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
$errorCode = $errorCode ?? 404;
$errorMessage = $errorMessage ?? 'Page not found.';
http_response_code($errorCode);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SafePaste — <?= $errorCode ?></title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<div class="page-wrapper">
<header class="site-header">
<a href="/" class="logo">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="4" rx="1"/><path d="M5 4h-1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1"/><path d="M9 12h6M9 16h4"/></svg>
SafePaste
</a>
</header>
<main class="main-card error-card">
<div class="error-code"><?= $errorCode ?></div>
<p class="error-message"><?= htmlspecialchars($errorMessage) ?></p>
<a href="/" class="btn-primary" style="display:inline-flex;margin-top:2rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Home
</a>
</main>
<footer class="site-footer">
AES-256-CBC encrypted &middot; Open source &middot; No tracking
</footer>
</div>
</body>
</html>

View File

@@ -1,28 +1,108 @@
<?php
$action = $_GET['action'] ?? '';
if ($action === 'save') {
if (($_GET['action'] ?? '') === 'save') {
require __DIR__ . '/../app/controllers/SaveController.php';
exit;
}
?>
<link rel="stylesheet" href="/assets/css/style.css">
<script src="/assets/js/main.js"></script>
<script src="/assets/js/app.js"></script>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SafePaste — Secure Text Sharing</title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<div class="page-wrapper">
<header class="site-header">
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="4" rx="1"/><path d="M5 4h-1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1"/><path d="M9 12h6M9 16h4"/></svg>
SafePaste
</div>
<span class="header-badge">End-to-end encrypted</span>
</header>
<main class="main-card">
<div class="card-header">
<h1>New Paste</h1>
<p>Your text is encrypted before storage. Share safely.</p>
</div>
<form method="POST" action="?action=save" id="pasteForm">
<textarea name="text" placeholder="Enter your text..." required></textarea>
<input type="text" class="usepassword" name="password" placeholder="Password (Optional)">
<select name="expire">
<option value="60">1 MIN</option>
<option value="3600">1 Hours</option>
<option value="86400">2 Day</option>
<option value="0">without time</option>
<form id="pasteForm" novalidate>
<div class="field">
<div class="textarea-wrapper">
<textarea
id="pasteText"
name="text"
placeholder="Paste your text, code, or notes here..."
rows="12"
spellcheck="false"
autocomplete="off"
></textarea>
<div class="char-counter"><span id="charCount">0</span> chars</div>
</div>
</div>
<div class="options-row">
<div class="field-group">
<label for="pasteExpire">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Expires
</label>
<select id="pasteExpire" name="expire">
<option value="300">5 minutes</option>
<option value="3600" selected>1 hour</option>
<option value="86400">1 day</option>
<option value="604800">1 week</option>
<option value="0">Never</option>
</select>
<button type="submit">Create link</button>
</form>
</div>
<div id="result"></div>
<div class="field-group">
<label for="pastePassword">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Password
</label>
<input
id="pastePassword"
type="password"
name="password"
placeholder="Optional password"
autocomplete="new-password"
>
</div>
</div>
<button type="submit" class="btn-primary" id="submitBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span>Create Secure Link</span>
</button>
</form>
<div id="result" class="result-box" hidden>
<div class="result-header">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Paste created successfully!
</div>
<div class="result-link-row">
<input type="text" id="pasteLink" readonly>
<button type="button" id="copyLinkBtn" class="btn-copy">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy
</button>
</div>
<a id="viewLink" href="#" class="result-open-link" target="_blank">
Open paste
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
</div>
</main>
<footer class="site-footer">
AES-256-CBC encrypted &middot; Open source &middot; No tracking
</footer>
</div>
<script src="/assets/js/app.js"></script>
</body>
</html>

21
public/router.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
$uri = $_SERVER['REQUEST_URI'];
$path = parse_url($uri, PHP_URL_PATH);
// Strip trailing slash
$path = rtrim($path, '/') ?: '/';
// Route: /view/{id}
if (preg_match('#^/view/([a-f0-9]+)$#i', $path, $m)) {
$_GET['id'] = $m[1];
require __DIR__ . '/view.php';
exit;
}
// Route: static assets
if (preg_match('#\.(css|js|png|jpg|ico|svg|woff2?)$#', $path)) {
return false; // let PHP built-in server serve the file
}
// Route: / or /index.php
require __DIR__ . '/index.php';

View File

@@ -1,5 +1,95 @@
<?php
<?php require __DIR__ . '/../app/controllers/ViewController.php'; ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SafePaste — View</title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<div class="page-wrapper">
<header class="site-header">
<a href="/" class="logo">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="4" rx="1"/><path d="M5 4h-1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1"/><path d="M9 12h6M9 16h4"/></svg>
SafePaste
</a>
<a href="/" class="btn-new-paste">+ New Paste</a>
</header>
require __DIR__ . '/../app/controllers/ViewController.php';
<main class="main-card">
<?php if ($needsPassword): ?>
?>
<div class="card-header">
<div class="lock-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h1>Password Required</h1>
<p>This paste is protected. Enter the password to view it.</p>
</div>
<form method="POST" action="<?= htmlspecialchars($_SERVER['REQUEST_URI']) ?>" class="password-form">
<?php if ($wrongPassword): ?>
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Incorrect password. Please try again.
</div>
<?php endif; ?>
<div class="field">
<input
type="password"
name="password"
class="input-password"
placeholder="Enter password..."
autofocus
autocomplete="current-password"
>
</div>
<button type="submit" class="btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Unlock Paste
</button>
</form>
<?php else: ?>
<div class="paste-meta">
<?php if ($data['expire_time']): ?>
<span class="meta-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Expires <?= htmlspecialchars(date('M j, Y H:i', (int)$data['expire_time'])) ?>
</span>
<?php else: ?>
<span class="meta-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
No expiry
</span>
<?php endif; ?>
<span class="meta-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
<?= number_format(mb_strlen($decrypted)) ?> chars
</span>
</div>
<div class="paste-box">
<div class="paste-toolbar">
<span class="paste-toolbar-title">Content</span>
<button id="copyBtn" class="btn-icon" title="Copy to clipboard">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>Copy</span>
</button>
</div>
<pre id="pasteContent"><?= htmlspecialchars($decrypted) ?></pre>
</div>
<?php endif; ?>
</main>
<footer class="site-footer">
AES-256-CBC encrypted &middot; Open source &middot; No tracking
</footer>
</div>
<script src="/assets/js/app.js"></script>
</body>
</html>