Update -> refactor and optimize UI , code ,...
This commit is contained in:
5
.env
Normal file
5
.env
Normal 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
8
.env.example
Normal 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
2
.gitignore
vendored
@@ -1 +1 @@
|
|||||||
.env
|
.env.DS_Store
|
||||||
|
|||||||
68
README.md
Normal file
68
README.md
Normal 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
|
||||||
|
```
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
$env = parse_ini_file(__DIR__ . '/../../.env');
|
$envFile = __DIR__ . '/../../.env';
|
||||||
|
$env = file_exists($envFile) ? parse_ini_file($envFile) : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'db' => [
|
'db' => [
|
||||||
'host' => $env['DB_HOST'] ?? 'localhost',
|
'host' => $env['DB_HOST'] ?? 'localhost',
|
||||||
'name' => $env['DB_NAME'] ?? 'paste',
|
'name' => $env['DB_NAME'] ?? 'paste',
|
||||||
'user' => $env['DB_USER'] ?? 'root',
|
'user' => $env['DB_USER'] ?? 'root',
|
||||||
'pass' => $env['DB_PASS'] ?? ''
|
'pass' => $env['DB_PASS'] ?? '',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
],
|
],
|
||||||
'redis' => [
|
'redis' => [
|
||||||
'host' => '127.0.0.1',
|
'host' => $env['REDIS_HOST'] ?? '127.0.0.1',
|
||||||
'port' => 6379
|
'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'
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/../core/security.php';
|
require_once __DIR__ . '/../core/security.php';
|
||||||
$pdo = require __DIR__ . '/../core/db.php';
|
$pdo = require __DIR__ . '/../core/db.php';
|
||||||
require __DIR__ . '/../models/Paste.php';
|
require_once __DIR__ . '/../models/Paste.php';
|
||||||
$config = require __DIR__ . '/../config/config.php';
|
$config = require __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
header('Location: /index.php');
|
header('Location: /');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$text = $_POST['text'] ?? '';
|
|
||||||
|
$text = trim($_POST['text'] ?? '');
|
||||||
$password = $_POST['password'] ?? '';
|
$password = $_POST['password'] ?? '';
|
||||||
$expire = isset($_POST['expire']) ? intval($_POST['expire']) : 0;
|
$expire = isset($_POST['expire']) ? (int)$_POST['expire'] : 0;
|
||||||
if (trim($text) === '') {
|
|
||||||
die('Text is required');
|
if ($text === '') {
|
||||||
|
jsonResponse(['success' => false, 'message' => 'Text cannot be empty.'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$id = generateId();
|
$id = generateId();
|
||||||
$enc = encryptText($text, $config['master_key']);
|
$enc = encryptText($text, $config['app']['master_key']);
|
||||||
$password_hash = $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : null;
|
$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 = 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;
|
jsonResponse(['success' => true, 'url' => $url]);
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
"success" => true,
|
|
||||||
"url" => $url
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
|
|||||||
@@ -1,59 +1,56 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/../core/security.php';
|
require_once __DIR__ . '/../core/security.php';
|
||||||
$pdo = require __DIR__ . '/../core/db.php';
|
$pdo = require __DIR__ . '/../core/db.php';
|
||||||
require __DIR__ . '/../models/Paste.php';
|
require_once __DIR__ . '/../models/Paste.php';
|
||||||
$config = require __DIR__ . '/../config/config.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);
|
$paste = new Paste($pdo);
|
||||||
$data = $paste->get($id);
|
$data = $paste->get($id);
|
||||||
|
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
die('Paste not found.');
|
$errorCode = 404;
|
||||||
}
|
$errorMessage = 'Paste not found.';
|
||||||
|
require __DIR__ . '/../../public/error.php';
|
||||||
if ($data['expire_time'] !== null && time() > (int)$data['expire_time']) {
|
|
||||||
die('Paste has expired.');
|
|
||||||
}
|
|
||||||
|
|
||||||
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>";
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password_verify($_POST['password'], $data['password_hash'])) {
|
if ($data['expire_time'] !== null && time() > (int)$data['expire_time']) {
|
||||||
die('Wrong password.');
|
$errorCode = 410;
|
||||||
|
$errorMessage = 'This paste has expired.';
|
||||||
|
require __DIR__ . '/../../public/error.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$decrypted = decryptText($data['encrypted_text'], $data['iv'], $config['master_key']);
|
if (!$needsPassword) {
|
||||||
|
$decrypted = decryptText($data['encrypted_text'], $data['iv'], $config['app']['master_key']);
|
||||||
if ($decrypted === false) {
|
if ($decrypted === false) {
|
||||||
die('Decryption failed.');
|
$errorCode = 500;
|
||||||
|
$errorMessage = 'Decryption failed. The paste may be corrupted.';
|
||||||
|
require __DIR__ . '/../../public/error.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|
||||||
<!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>
|
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
$config = require __DIR__ . '/../config/config.php';
|
$config = require __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = new PDO(
|
$dsn = sprintf(
|
||||||
"mysql:host={$config['db']['host']};dbname={$config['db']['name']};charset=utf8mb4",
|
'mysql:host=%s;dbname=%s;charset=%s',
|
||||||
$config['db']['user'],
|
$config['db']['host'],
|
||||||
$config['db']['pass'],
|
$config['db']['name'],
|
||||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
$config['db']['charset']
|
||||||
);
|
);
|
||||||
} catch (Exception $e) {
|
$pdo = new PDO($dsn, $config['db']['user'], $config['db']['pass'], [
|
||||||
die('Database connection error');
|
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;
|
return $pdo;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
function redisClient()
|
function redisClient(): Redis
|
||||||
{
|
{
|
||||||
static $redis = null;
|
static $redis = null;
|
||||||
if ($redis === null) {
|
if ($redis === null) {
|
||||||
$config = require __DIR__ . '/../config/config.php';
|
$config = require __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
$redis = new Redis();
|
$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;
|
return $redis;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
function encryptText($text, $key) {
|
|
||||||
|
function encryptText(string $text, string $key): array
|
||||||
|
{
|
||||||
$iv = random_bytes(16);
|
$iv = random_bytes(16);
|
||||||
$cipher = openssl_encrypt($text, 'AES-256-CBC', $key, 0, $iv);
|
$cipher = openssl_encrypt($text, 'AES-256-CBC', $key, 0, $iv);
|
||||||
|
if ($cipher === false) {
|
||||||
|
throw new RuntimeException('Encryption failed.');
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'cipher' => $cipher,
|
'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));
|
return openssl_decrypt($cipher, 'AES-256-CBC', $key, 0, base64_decode($iv));
|
||||||
}
|
}
|
||||||
function generateId() {
|
|
||||||
|
function generateId(): string
|
||||||
|
{
|
||||||
return bin2hex(random_bytes(16));
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,62 +1,58 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../core/db.php';
|
|
||||||
require_once __DIR__ . '/../core/redis.php';
|
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/redis.php';
|
||||||
|
|
||||||
class Paste
|
class Paste
|
||||||
{
|
{
|
||||||
private $pdo;
|
private PDO $pdo;
|
||||||
public function __construct($pdo)
|
|
||||||
|
public function __construct(PDO $pdo)
|
||||||
{
|
{
|
||||||
$this->pdo = $pdo;
|
$this->pdo = $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save($id, $encrypted_text, $iv, $expire_time, $password_hash)
|
public function save(
|
||||||
{
|
string $id,
|
||||||
if ($expire_time === null) {
|
string $encrypted_text,
|
||||||
$stmt = $this->pdo->prepare("INSERT INTO pastes(id, encrypted_text, iv, expire_time, password_hash)
|
string $iv,
|
||||||
VALUES (?, ?, ?, NULL, ?)");
|
?int $expire_time,
|
||||||
return $stmt->execute([$id, $encrypted_text, $iv, $password_hash]);
|
?string $password_hash
|
||||||
}
|
): bool {
|
||||||
|
if ($expire_time !== null) {
|
||||||
$redis = redisClient();
|
|
||||||
|
|
||||||
$expire_time = (int)$expire_time;
|
|
||||||
$ttl = max(1, $expire_time - time());
|
$ttl = max(1, $expire_time - time());
|
||||||
|
$redis = redisClient();
|
||||||
|
$redis->setex("paste:{$id}", $ttl, json_encode([
|
||||||
$redis->setex(
|
|
||||||
"paste:$id",
|
|
||||||
$ttl,
|
|
||||||
json_encode([
|
|
||||||
'encrypted_text' => $encrypted_text,
|
'encrypted_text' => $encrypted_text,
|
||||||
'iv' => $iv,
|
'iv' => $iv,
|
||||||
'password_hash' => $password_hash
|
'password_hash' => $password_hash,
|
||||||
])
|
]));
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
return true;
|
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();
|
$redis = redisClient();
|
||||||
|
$raw = $redis->get("paste:{$id}");
|
||||||
|
|
||||||
$data = $redis->get("paste:$id");
|
if ($raw !== false) {
|
||||||
if ($data !== false) {
|
$json = json_decode($raw, true);
|
||||||
$json = json_decode($data, true);
|
|
||||||
return [
|
return [
|
||||||
'encrypted_text' => $json['encrypted_text'],
|
'encrypted_text' => $json['encrypted_text'],
|
||||||
'iv' => $json['iv'],
|
'iv' => $json['iv'],
|
||||||
'password_hash' => $json['password_hash'],
|
'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]);
|
$stmt->execute([$id]);
|
||||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
return $stmt->fetch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -1,83 +1,512 @@
|
|||||||
* {
|
/* ─── Reset & Base ─── */
|
||||||
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 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 {
|
body {
|
||||||
background: #0f172a;
|
background: var(--bg);
|
||||||
color: #e2e8f0;
|
color: var(--text);
|
||||||
padding: 40px;
|
font-family: var(--font-sans);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* container */
|
/* ─── Page Layout ─── */
|
||||||
.container {
|
.page-wrapper {
|
||||||
max-width: 900px;
|
display: flex;
|
||||||
margin: auto;
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* title */
|
/* ─── Header ─── */
|
||||||
h1 {
|
.site-header {
|
||||||
margin-bottom: 20px;
|
display: flex;
|
||||||
font-size: 28px;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* textarea */
|
.logo {
|
||||||
textarea {
|
display: flex;
|
||||||
width: 100%;
|
align-items: center;
|
||||||
min-height: 300px;
|
gap: 0.5rem;
|
||||||
background: #020617;
|
font-weight: 700;
|
||||||
border: 1px solid #334155;
|
font-size: 1.05rem;
|
||||||
color: #e2e8f0;
|
letter-spacing: -0.01em;
|
||||||
padding: 12px;
|
color: var(--text);
|
||||||
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;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
.logo svg { color: var(--accent); }
|
||||||
text-decoration: underline;
|
|
||||||
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,106 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
const form = document.getElementById("pasteForm");
|
/* ── Create-paste page ── */
|
||||||
const resultBox = document.getElementById("result");
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(form);
|
if (!pasteText || pasteText.value.trim() === '') {
|
||||||
|
pasteText.focus();
|
||||||
const response = await fetch("/index.php?action=save", {
|
pasteText.style.borderColor = 'var(--error)';
|
||||||
method: "POST",
|
setTimeout(() => pasteText.style.borderColor = '', 1200);
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
resultBox.innerHTML = "<div class='error'>" + data.message + "</div>";
|
|
||||||
return;
|
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 = `
|
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;">
|
/* ── Copy link button (result box) ── */
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
<button id="copyBtn">Copy Link</button>
|
const btn = e.target.closest('#copyLinkBtn');
|
||||||
`;
|
if (!btn) return;
|
||||||
|
const val = document.getElementById('pasteLink')?.value;
|
||||||
document.getElementById("copyBtn").addEventListener("click", () => {
|
if (!val) return;
|
||||||
const input = document.getElementById("pasteLink");
|
copyToClipboard(val, btn, 'Copied!', 'Copy');
|
||||||
navigator.clipboard.writeText(input.value);
|
|
||||||
alert("Link copied!");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
/* merged into app.js */
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|||||||
35
public/error.php
Normal file
35
public/error.php
Normal 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 · Open source · No tracking
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
112
public/index.php
112
public/index.php
@@ -1,28 +1,108 @@
|
|||||||
<?php
|
<?php
|
||||||
$action = $_GET['action'] ?? '';
|
if (($_GET['action'] ?? '') === 'save') {
|
||||||
|
|
||||||
if ($action === 'save') {
|
|
||||||
require __DIR__ . '/../app/controllers/SaveController.php';
|
require __DIR__ . '/../app/controllers/SaveController.php';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
<!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">
|
<link rel="stylesheet" href="/assets/css/style.css">
|
||||||
<script src="/assets/js/main.js"></script>
|
</head>
|
||||||
<script src="/assets/js/app.js"></script>
|
<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">
|
<form id="pasteForm" novalidate>
|
||||||
<textarea name="text" placeholder="Enter your text..." required></textarea>
|
<div class="field">
|
||||||
<input type="text" class="usepassword" name="password" placeholder="Password (Optional)">
|
<div class="textarea-wrapper">
|
||||||
<select name="expire">
|
<textarea
|
||||||
<option value="60">1 MIN</option>
|
id="pasteText"
|
||||||
<option value="3600">1 Hours</option>
|
name="text"
|
||||||
<option value="86400">2 Day</option>
|
placeholder="Paste your text, code, or notes here..."
|
||||||
<option value="0">without time</option>
|
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>
|
</select>
|
||||||
<button type="submit">Create link</button>
|
</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>
|
</form>
|
||||||
|
|
||||||
<div id="result"></div>
|
<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 · Open source · No tracking
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
21
public/router.php
Normal file
21
public/router.php
Normal 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';
|
||||||
@@ -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 · Open source · No tracking
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user