Add files via upload
This commit is contained in:
@@ -2,451 +2,551 @@
|
||||
<img src="https://sphost.theazizi.ir/favicon.svg" width="100" height="100" alt="Secure Pastebin Logo">
|
||||
</p>
|
||||
|
||||
# 🔐 Secure Pastebin (Self-Hosted Ver)
|
||||
# 🔐 Secure Pastebin (Self-Hosted)
|
||||
|
||||
> **Self-Hosted, Zero-Knowledge, End-to-End Encrypted Pastebin**
|
||||
>
|
||||
> Share sensitive messages securely. Server cannot read your data. Ever.
|
||||
> **Self-hosted, zero-knowledge, end-to-end encrypted pastebin built with PHP, MySQL, Web Crypto API, and a responsive single-page UI.**
|
||||
|
||||
Share sensitive text securely. Encryption happens in the browser before upload, the server stores ciphertext only, and the decryption key stays in the URL fragment (`#...`) so it is never sent to the server.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://php.net)
|
||||
[](https://en.wikipedia.org/wiki/Galois/Counter_Mode)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#installation)
|
||||
[](#api)
|
||||
|
||||
🌐 **Live Demo:** https://sphost.theazizi.ir
|
||||
☁️ [**Cloudflare Worker Version**](https://github.com/TheGreatAzizi/Secure-Pastebin-Cloudflare-Worker)
|
||||
☁️ **Cloudflare Worker version:** https://github.com/TheGreatAzizi/Secure-Pastebin-Cloudflare-Worker
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
| Feature | Description | Security Impact |
|
||||
|---------|-------------|---------------|
|
||||
| 🔒 **E2E Encryption** | AES-256-GCM in browser before transmission | Server sees only ciphertext |
|
||||
| 🛡️ **Zero-Knowledge** | Server has zero access to keys or plaintext | Mathematically provable |
|
||||
| 🔑 **Password Protection** | PBKDF2 with 100,000 iterations | Brute-force resistant |
|
||||
| 🔥 **Burn After Read** | Auto-delete after first access | Forward secrecy |
|
||||
| ⏱️ **Auto-Expiration** | 1 hour to 30 days configurable | Limits exposure window |
|
||||
| 🌐 **RTL Support** | Persian, Arabic, Hebrew typography | Accessibility |
|
||||
| 📱 **Responsive** | Mobile-first design | Usability |
|
||||
- 🔒 **Client-side AES-256-GCM encryption** using the native Web Crypto API
|
||||
- 🛡️ **Zero-knowledge architecture** — server never receives plaintext, password, or key
|
||||
- 🧾 **Optional subject field** included inside the encrypted payload
|
||||
- 🔑 **Optional password protection** with PBKDF2 (100,000 iterations, SHA-256)
|
||||
- 🔥 **Burn after reading** support
|
||||
- ⏱️ **Preset expiration** plus **custom expiration date & time**
|
||||
- 📝 **Markdown authoring + rendering**
|
||||
- compact formatting toolbar in the composer
|
||||
- Markdown is rendered **after decryption only**
|
||||
- 🔗 **Two share-link formats**
|
||||
- full link: `/p/{id}#key`
|
||||
- short link: `/#id:key`
|
||||
- 📋 **Copy actions** for full link, short link, and decrypted text
|
||||
- 📱 **Responsive UI** with improved mobile layout
|
||||
- 🌍 **RTL-aware text handling** for Persian / Arabic / Hebrew content
|
||||
- 🔤 **No external font CDN** — uses a local Vazirmatn-based font stack
|
||||
- ⚙️ **Documented HTTP API** with `/api/docs`
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Cryptography Architecture
|
||||
## 🔐 How the security model works
|
||||
|
||||
### Zero-Knowledge Proof
|
||||
1. The browser generates or derives the encryption key locally.
|
||||
2. The browser encrypts the payload locally using AES-256-GCM.
|
||||
3. The server receives only:
|
||||
- paste ID
|
||||
- IV
|
||||
- ciphertext
|
||||
- metadata such as expiration / burn-after-read / password flag
|
||||
4. The decryption key is kept in the URL fragment (`#...`). URL fragments are not sent in normal HTTP requests.
|
||||
5. The recipient opens the link, downloads the encrypted payload, and decrypts it locally.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ ZERO-KNOWLEDGE GUARANTEE │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ USER BROWSER SERVER / DATABASE │
|
||||
│ ───────────── ───────────────── │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ Generate │ ──NOT SENT──────► │ │ │
|
||||
│ │ AES-256 Key │ │ NO KEYS STORED │ │
|
||||
│ └─────────────┘ │ │ │
|
||||
│ └─────────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ Encrypt │ ──NOT SENT──────────────────────────────────►│
|
||||
│ │ Plaintext │ │
|
||||
│ │ with Key │ │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ Send: │ ──HTTPS─────────► │ Store: │ │
|
||||
│ │ • ID │ │ • ID │ │
|
||||
│ │ • IV │ │ • IV │ │
|
||||
│ │ • Ciphertext│ │ • Ciphertext │ │
|
||||
│ │ • Metadata │ │ • Metadata │ │
|
||||
│ └─────────────┘ │ │ │
|
||||
│ │ NO PLAINTEXT │ │
|
||||
│ ┌─────────────┐ │ NO PASSWORD │ │
|
||||
│ │ Key Stored │ │ NO KEY │ │
|
||||
│ │ in URL: │ │ │ │
|
||||
│ │ │ └─────────────────┘ │
|
||||
│ │ #id:key ◄─┘ NEVER in HTTP headers │
|
||||
│ │ │ │
|
||||
│ └─────────────┘ Fragment not sent to server │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
### Important note
|
||||
|
||||
If the full share URL is lost, the message is **not recoverable**. The server cannot reconstruct the decryption key.
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Current stack
|
||||
|
||||
- **Frontend:** HTML, CSS, vanilla JavaScript
|
||||
- **Crypto:** Web Crypto API
|
||||
- **Backend/router:** PHP
|
||||
- **Database:** MySQL / MariaDB
|
||||
- **Storage model:** encrypted payload + metadata only
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What is included in this version
|
||||
|
||||
Compared with the older README, the current app now includes compact share links, custom expiration timestamps, a Markdown toolbar and renderer, password strength feedback, an API docs page, updated responsive layout, and a local-font setup with no external font dependency. The older README you uploaded still documents the earlier API shape and older sharing format. fileciteturn0file0
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Share link formats
|
||||
|
||||
### Full share link
|
||||
|
||||
```text
|
||||
https://your-domain.com/p/AbCdEf1234567890#BASE64URL_KEY
|
||||
```
|
||||
|
||||
### Technical Specifications
|
||||
### Full share link with password flag
|
||||
|
||||
| Component | Standard | Parameters |
|
||||
|-----------|----------|------------|
|
||||
| Symmetric Encryption | AES-256-GCM | 256-bit key, 96-bit nonce |
|
||||
| Key Derivation | PBKDF2 | 100,000 iterations, SHA-256 |
|
||||
| Random Generation | CSPRNG | Crypto.getRandomValues() |
|
||||
| Key Encoding | Base64URL | URL-safe, no padding |
|
||||
| Transport Security | TLS 1.3 | Certificate pinning recommended |
|
||||
|
||||
### Encryption Flow
|
||||
|
||||
```javascript
|
||||
// 1. Key Generation (Browser)
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// 2. Encryption (Browser - before network)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
new TextEncoder().encode(plaintext)
|
||||
);
|
||||
|
||||
// 3. Transmission (HTTPS only)
|
||||
fetch('/api/create', {
|
||||
body: JSON.stringify({
|
||||
id: randomId,
|
||||
encryptedData: { iv: [...iv], data: [...ciphertext] },
|
||||
// NO KEY HERE - key stays in browser/URL
|
||||
})
|
||||
});
|
||||
```text
|
||||
https://your-domain.com/p/AbCdEf1234567890#BASE64URL_SALT:pwd
|
||||
```
|
||||
|
||||
### Short share link
|
||||
|
||||
```text
|
||||
https://your-domain.com/#AbCdEf1234567890:BASE64URL_KEY
|
||||
```
|
||||
|
||||
### Short share link with password flag
|
||||
|
||||
```text
|
||||
https://your-domain.com/#AbCdEf1234567890:BASE64URL_SALT:pwd
|
||||
```
|
||||
|
||||
### ID compatibility
|
||||
|
||||
The backend currently accepts these ID formats:
|
||||
|
||||
- legacy **32-character lowercase hex** IDs
|
||||
- compact **16-character URL-safe IDs** (`[A-Za-z0-9_-]{16}`)
|
||||
|
||||
The UI currently generates the compact 16-character format by default.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Encrypted payload format
|
||||
|
||||
The browser encrypts a JSON payload. Subject and content are both inside the encrypted blob.
|
||||
|
||||
Example logical structure before encryption:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Optional subject",
|
||||
"content": "Secret message with **Markdown** support"
|
||||
}
|
||||
```
|
||||
|
||||
The server never sees this plaintext object.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### System Requirements
|
||||
## 1) Database
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
|-------------|---------|-------------|
|
||||
| PHP | 7.4 | 8.1+ |
|
||||
| MySQL | 5.7 | 8.0+ |
|
||||
| Web Server | Apache 2.4 | Nginx + Apache |
|
||||
| SSL | Required | Let's Encrypt |
|
||||
|
||||
### Step 1: Database Setup
|
||||
Create a database, then import `database.sql`.
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS secure_pastebin
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE secure_pastebin;
|
||||
|
||||
CREATE TABLE pastes (
|
||||
CREATE TABLE IF NOT EXISTS pastes (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
burn_after_read TINYINT(1) DEFAULT 0,
|
||||
has_password TINYINT(1) DEFAULT 0,
|
||||
views INT DEFAULT 0,
|
||||
INDEX idx_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
views INT DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_expires ON pastes(expires_at);
|
||||
```
|
||||
|
||||
### Step 2: Configuration
|
||||
## 2) Configure PHP
|
||||
|
||||
Edit `index.php` (lines 5-8):
|
||||
Edit the database constants in `index.php`:
|
||||
|
||||
```php
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_USER', 'your_cpanel_username_dbuser');
|
||||
define('DB_PASS', 'your_secure_random_password');
|
||||
define('DB_NAME', 'your_cpanel_username_pastebin');
|
||||
const DB_HOST = 'localhost';
|
||||
const DB_USER = 'your_db_user';
|
||||
const DB_PASS = 'your_db_password';
|
||||
const DB_NAME = 'your_db_name';
|
||||
```
|
||||
|
||||
### Step 3: File Upload
|
||||
## 3) Upload files
|
||||
|
||||
```
|
||||
Upload the project files to your web root.
|
||||
|
||||
```text
|
||||
/public_html/
|
||||
├── index.php # API backend + router
|
||||
├── index.html # Single-page application
|
||||
├── style.css # Complete styling (RTL included)
|
||||
├── script.js # Web Crypto implementation
|
||||
├── .htaccess # URL rewriting rules
|
||||
└── database.sql # Schema (already imported)
|
||||
├── .htaccess
|
||||
├── api-docs.php
|
||||
├── database.sql
|
||||
├── index.html
|
||||
├── index.php
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
├── script.js
|
||||
└── style.css
|
||||
```
|
||||
|
||||
### Step 4: SSL Enforcement
|
||||
## 4) Enable URL rewriting
|
||||
|
||||
**cPanel Method:**
|
||||
1. SSL/TLS Status → Run AutoSSL
|
||||
2. Force HTTPS Redirect: ON
|
||||
Apache `.htaccess` used by the project:
|
||||
|
||||
**Cloudflare Method:**
|
||||
1. DNS proxied through Cloudflare
|
||||
2. SSL/TLS mode: Full (strict)
|
||||
3. Always Use HTTPS: ON
|
||||
```apache
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
### Step 5: Verification Checklist
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
- [ ] Database connection successful (check error logs)
|
||||
- [ ] `POST /api/create` returns 201 with valid JSON
|
||||
- [ ] `GET /api/get/{id}` returns encrypted data
|
||||
- [ ] Auto-cleanup: Expired rows delete automatically
|
||||
RewriteRule ^ index.php [L]
|
||||
```
|
||||
|
||||
## 5) Use HTTPS
|
||||
|
||||
`crypto.subtle` requires a secure context in production. Use:
|
||||
|
||||
- HTTPS on your domain, or
|
||||
- `localhost` during local development
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Structure
|
||||
## 📁 Project structure
|
||||
|
||||
### Schema Overview
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.php` | router + API backend |
|
||||
| `index.html` | single-page app UI |
|
||||
| `script.js` | encryption, decryption, UI behavior, Markdown tools |
|
||||
| `style.css` | responsive styling |
|
||||
| `api-docs.php` | human-friendly API documentation page |
|
||||
| `database.sql` | MySQL schema |
|
||||
| `.htaccess` | Apache rewrite rules |
|
||||
|
||||
| Column | Type | Nullable | Description |
|
||||
|--------|------|----------|-------------|
|
||||
| `id` | VARCHAR(32) | NO | Cryptographically random hex |
|
||||
| `data` | TEXT | NO | JSON: {iv: number[], data: number[]} |
|
||||
| `created_at` | BIGINT | NO | Unix timestamp (ms) |
|
||||
| `expires_at` | BIGINT | NO | Auto-deletion trigger (s) |
|
||||
| `burn_after_read` | TINYINT(1) | NO | Boolean flag |
|
||||
| `has_password` | TINYINT(1) | NO | PBKDF2 required flag |
|
||||
| `views` | INT | NO | Access counter |
|
||||
---
|
||||
|
||||
### Real-World Example
|
||||
## 🧠 Composer UI highlights
|
||||
|
||||
**User Input:**
|
||||
```
|
||||
سلام، این پیام محرمانه من است.
|
||||
Hello, this is my secret message.
|
||||
### Secure message composer
|
||||
|
||||
- optional subject
|
||||
- Markdown toolbar
|
||||
- multiline textarea
|
||||
- preset expiration dropdown
|
||||
- custom expiration datetime picker
|
||||
- password-protection toggle
|
||||
- burn-after-reading toggle
|
||||
- password strength meter
|
||||
|
||||
### Decrypted result view
|
||||
|
||||
- subject display
|
||||
- rendered Markdown output
|
||||
- copy decrypted text button
|
||||
- burn-after-read warning when applicable
|
||||
|
||||
---
|
||||
|
||||
## ✍️ Markdown support
|
||||
|
||||
Markdown is stored as plain text inside the encrypted payload and is rendered only after decryption.
|
||||
|
||||
Supported authoring helpers include:
|
||||
|
||||
- **Bold**
|
||||
- *Italic*
|
||||
- ~~Strikethrough~~
|
||||
- headings
|
||||
- quotes
|
||||
- bullet lists
|
||||
- numbered lists
|
||||
- links
|
||||
- inline code
|
||||
- fenced code blocks
|
||||
|
||||
This keeps the stored payload simple while still giving the recipient a readable result view.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API
|
||||
|
||||
### Docs page
|
||||
|
||||
Once deployed, the built-in docs page is available at:
|
||||
|
||||
```text
|
||||
https://your-domain.com/api/docs
|
||||
```
|
||||
|
||||
**What Gets Stored in Database:**
|
||||
### Base notes
|
||||
|
||||
- API stores **ciphertext only**
|
||||
- API does **not** perform encryption for you
|
||||
- client must encrypt before sending data
|
||||
- password or raw key must **never** be sent to the server
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| `GET` | `/api/health` | service health check |
|
||||
| `GET` | `/api/options` | API capabilities, limits, routes |
|
||||
| `GET` | `/api/docs` | HTML API documentation |
|
||||
| `POST` | `/api/pastes` | create a paste |
|
||||
| `POST` | `/api/pastes/{id}` | create a paste with a specific ID |
|
||||
| `GET` | `/api/pastes/{id}` | fetch encrypted payload |
|
||||
| `GET` | `/api/pastes/{id}/meta` | fetch metadata only |
|
||||
| `POST` | `/api/create` | legacy create route |
|
||||
| `GET` | `/api/get/{id}` | legacy read route |
|
||||
|
||||
### Limits
|
||||
|
||||
From the current backend:
|
||||
|
||||
- minimum expiration: **300 seconds**
|
||||
- maximum expiration: **31536000 seconds** (365 days)
|
||||
- max encrypted payload size: **4 MiB**
|
||||
|
||||
### Supported create payload formats
|
||||
|
||||
You can create a paste using any of these shapes:
|
||||
|
||||
1. nested byte arrays
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "7a3f9e2b8c1d4e5f6a7b8c9d0e1f2a3b",
|
||||
"data": "{\"iv\":[187,45,92,201,78,34,156,89,234,12,67,189],\"data\":[45,189,234,67,123,89,45,234,89,123,45,67,234,89,123,45,67,234,89,123,45,67,234,89,123,45,67,234,89,123,45,67,234,89,123,45,67,234,89,123,45,67,234,89,123,45]}",
|
||||
"created_at": 1708368000000,
|
||||
"expires_at": 1708454400,
|
||||
"burn_after_read": 0,
|
||||
"has_password": 1,
|
||||
"encryptedData": {
|
||||
"iv": [12, 34, 56],
|
||||
"data": [99, 88, 77]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. top-level byte arrays
|
||||
|
||||
```json
|
||||
{
|
||||
"iv": [12, 34, 56],
|
||||
"data": [99, 88, 77]
|
||||
}
|
||||
```
|
||||
|
||||
3. nested base64url strings
|
||||
|
||||
```json
|
||||
{
|
||||
"encryptedData": {
|
||||
"ivBase64": "AAECAwQFBgcICQoL",
|
||||
"dataBase64": "mYh3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. top-level base64url strings
|
||||
|
||||
```json
|
||||
{
|
||||
"ivBase64": "AAECAwQFBgcICQoL",
|
||||
"dataBase64": "mYh3"
|
||||
}
|
||||
```
|
||||
|
||||
`ivBase64url` / `dataBase64url` are also accepted.
|
||||
|
||||
### Create request fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `id` | optional | custom paste ID |
|
||||
| `encryptedData` or equivalent | yes | ciphertext payload |
|
||||
| `expiresIn` | optional | expiry in seconds |
|
||||
| `customExpiresAt` | optional | exact Unix timestamp in seconds |
|
||||
| `burnAfterRead` | optional | delete after first successful read |
|
||||
| `hasPassword` | optional | indicates password is required on the client |
|
||||
|
||||
If `customExpiresAt` is present, it overrides `expiresIn`.
|
||||
|
||||
### Example: create a paste
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/pastes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "AbCdEf1234567890",
|
||||
"encryptedData": {
|
||||
"iv": [12, 34, 56, 78, 90, 12, 34, 56, 78, 90, 12, 34],
|
||||
"data": [189, 45, 78, 201, 156, 78, 33, 45]
|
||||
},
|
||||
"expiresIn": 86400,
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false
|
||||
}'
|
||||
```
|
||||
|
||||
### Example success response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"apiVersion": "1.2",
|
||||
"id": "AbCdEf1234567890",
|
||||
"expiresAt": 1735689600,
|
||||
"expiresIn": 86400,
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false,
|
||||
"url": "https://your-domain.com/p/AbCdEf1234567890",
|
||||
"retrieveUrl": "https://your-domain.com/api/pastes/AbCdEf1234567890",
|
||||
"metaUrl": "https://your-domain.com/api/pastes/AbCdEf1234567890/meta",
|
||||
"docsUrl": "https://your-domain.com/api/docs"
|
||||
}
|
||||
```
|
||||
|
||||
### Example: fetch encrypted payload
|
||||
|
||||
```bash
|
||||
curl https://your-domain.com/api/pastes/AbCdEf1234567890
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"apiVersion": "1.2",
|
||||
"id": "AbCdEf1234567890",
|
||||
"encryptedData": {
|
||||
"iv": [12, 34, 56, 78, 90, 12, 34, 56, 78, 90, 12, 34],
|
||||
"data": [189, 45, 78, 201, 156, 78, 33, 45],
|
||||
"ivBase64": "AAECAwQFBgcICQoL",
|
||||
"dataBase64": "mYh3"
|
||||
},
|
||||
"data": {
|
||||
"iv": [12, 34, 56, 78, 90, 12, 34, 56, 78, 90, 12, 34],
|
||||
"data": [189, 45, 78, 201, 156, 78, 33, 45]
|
||||
},
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false,
|
||||
"created": 1735603200000,
|
||||
"expiresAt": 1735689600,
|
||||
"views": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Burn-after-read behavior
|
||||
|
||||
When `burnAfterRead` is enabled, the first successful fetch from `GET /api/pastes/{id}` returns the ciphertext and then removes that paste from storage.
|
||||
|
||||
### Metadata endpoint
|
||||
|
||||
```bash
|
||||
curl https://your-domain.com/api/pastes/AbCdEf1234567890/meta
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"apiVersion": "1.2",
|
||||
"id": "AbCdEf1234567890",
|
||||
"shareUrl": "https://your-domain.com/p/AbCdEf1234567890",
|
||||
"retrieveUrl": "https://your-domain.com/api/pastes/AbCdEf1234567890",
|
||||
"created": 1735603200000,
|
||||
"expiresAt": 1735689600,
|
||||
"remainingSeconds": 86400,
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false,
|
||||
"views": 0
|
||||
}
|
||||
```
|
||||
|
||||
**URL Generated (CONTAINS KEY):**
|
||||
```
|
||||
https://yoursite.com/#7a3f9e2b8c1d4e5f6a7b8c9d0e1f2a3b:c2FsdHNhbHRzYWx0:pwd
|
||||
↑ ↑
|
||||
Base64URL salt Password flag
|
||||
(for PBKDF2)
|
||||
---
|
||||
|
||||
## 🧪 Health and options
|
||||
|
||||
### Health
|
||||
|
||||
```bash
|
||||
curl https://your-domain.com/api/health
|
||||
```
|
||||
|
||||
**Critical Security Note:**
|
||||
- The URL fragment (`#...`) is **never sent** in HTTP headers
|
||||
- Server logs contain: `GET /api/get/7a3f9e2b8c1d4e5f6a7b8c9d0e1f2a3b`
|
||||
- Server logs **never contain**: The key, plaintext, or password
|
||||
### Options
|
||||
|
||||
```bash
|
||||
curl https://your-domain.com/api/options
|
||||
```
|
||||
|
||||
`/api/options` returns API version, limits, presets, capabilities, endpoints, and notes.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 API Reference
|
||||
## 🌐 Browser compatibility
|
||||
|
||||
### Authentication
|
||||
| Browser | Status |
|
||||
|---------|--------|
|
||||
| Chrome / Edge | ✅ |
|
||||
| Firefox | ✅ |
|
||||
| Safari | ✅ |
|
||||
| Internet Explorer | ❌ |
|
||||
|
||||
No authentication required. Security through cryptography, not access control.
|
||||
|
||||
### Endpoints
|
||||
|
||||
#### POST `/api/create`
|
||||
|
||||
Create new encrypted paste.
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4e5f6... (32 hex characters)",
|
||||
"encryptedData": {
|
||||
"iv": [12, 34, 56, 78, 90, 12, 34, 56, 78, 90, 12, 34],
|
||||
"data": [189, 45, 78, 201, 156, 78, 33, 45, 182, 91, 234, 12, 67]
|
||||
},
|
||||
"expiresIn": 86400,
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (201):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"id": "a1b2c3d4e5f6...",
|
||||
"expiresIn": 86400,
|
||||
"hasPassword": false,
|
||||
"url": "https://yoursite.com/#a1b2c3d4e5f6:base64urlEncodedKey"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
| Code | Condition | Response |
|
||||
|------|-----------|----------|
|
||||
| 400 | Invalid ID format | `{"error": "Invalid ID format"}` |
|
||||
| 400 | Malformed JSON | `{"error": "Invalid JSON"}` |
|
||||
| 400 | Missing encryptedData | `{"error": "Invalid encrypted data format"}` |
|
||||
| 500 | Database failure | `{"error": "Failed to save paste"}` |
|
||||
|
||||
#### GET `/api/get/{id}`
|
||||
|
||||
Retrieve encrypted paste by ID.
|
||||
|
||||
**Parameters:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | string | 32-character hexadecimal ID |
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"iv": [12, 34, 56, 78, 90, 12, 34, 56, 78, 90, 12, 34],
|
||||
"data": [189, 45, 78, 201, 156, 78, 33, 45, 182, 91, 234, 12, 67]
|
||||
},
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false,
|
||||
"created": 1708368000000
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
| Code | Condition |
|
||||
|------|-----------|
|
||||
| 400 | Invalid ID format |
|
||||
| 404 | Paste not found or expired |
|
||||
A secure context is required for the Web Crypto API.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security Analysis
|
||||
## 🛡️ Security notes
|
||||
|
||||
### Threat Model Matrix
|
||||
### Protected well
|
||||
|
||||
| Attacker Capability | Data Access | Mitigation Status |
|
||||
|---------------------|-------------|-------------------|
|
||||
| **Passive network observer** | Encrypted TLS traffic only | ✅ Mitigated (TLS 1.3) |
|
||||
| **Database breach** | Ciphertext, metadata only | ✅ Mitigated (no keys stored) |
|
||||
| **Server compromise (root)** | Same as database | ✅ Mitigated (stateless design) |
|
||||
| **Backup tape theft** | Historical ciphertext | ✅ Mitigated (keys not in backups) |
|
||||
| **URL leak (no password)** | Full plaintext | ⚠️ **User responsibility** |
|
||||
| **URL leak (with password)** | Partial (needs password) | ⚠️ **Reduced risk** |
|
||||
| **Physical device access (post-decrypt)** | Plaintext in memory | ❌ **Not mitigated** |
|
||||
- database leaks still expose ciphertext only
|
||||
- server admins do not have plaintext or keys
|
||||
- password material stays client-side
|
||||
- URL fragment is not sent to the backend
|
||||
|
||||
### What We Protect Against
|
||||
### Not protected well
|
||||
|
||||
1. **Server-side attacks**: Even with full server compromise, attacker gains zero cryptographic material
|
||||
2. **Legal requests**: No plaintext or keys to surrender (technical impossibility)
|
||||
3. **Insider threats**: System administrators cannot access user content
|
||||
4. **Database leaks**: Ciphertext without keys is information-theoretically secure
|
||||
- malware on sender or recipient device
|
||||
- leaked full share URLs
|
||||
- weak passwords chosen by users
|
||||
- copied plaintext after decryption
|
||||
- screenshots or shoulder surfing
|
||||
|
||||
### What We Cannot Protect Against
|
||||
### Recommended operational practices
|
||||
|
||||
1. **Endpoint compromise**: Malware on sender/recipient device
|
||||
2. **Social engineering**: Users tricked into sharing URLs
|
||||
3. **Shoulder surfing**: Visual observation of decrypted content
|
||||
4. **Forensic analysis**: RAM dumps containing decrypted plaintext
|
||||
|
||||
### Operational Security Recommendations
|
||||
|
||||
| Risk | Mitigation Strategy |
|
||||
|------|---------------------|
|
||||
| URL in browser history | Use incognito/private mode |
|
||||
| URL in cloud sync | Disable browser sync for sensitive operations |
|
||||
| Screenshot exposure | Enable "Burn After Read" |
|
||||
| Password guessing | Use 12+ character random passwords |
|
||||
| Keylogger exposure | Use hardware security keys where possible |
|
||||
- send password separately from the URL
|
||||
- use burn-after-read for highly sensitive messages
|
||||
- prefer long random passwords
|
||||
- use private browsing on shared devices
|
||||
- do not paste highly sensitive links into third-party chatbots or analytics tools
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Browser Compatibility
|
||||
## 🎨 UX and design notes
|
||||
|
||||
| Browser | Version | Web Crypto API | Status |
|
||||
|---------|---------|----------------|--------|
|
||||
| Chrome | 37+ | ✅ Full | Recommended |
|
||||
| Firefox | 34+ | ✅ Full | Recommended |
|
||||
| Safari | 7+ | ✅ Full | Supported |
|
||||
| Edge | 12+ | ✅ Full | Supported |
|
||||
| Opera | 24+ | ✅ Full | Supported |
|
||||
| Internet Explorer | Any | ❌ None | Not Supported |
|
||||
|
||||
**Requirement:** Secure context (HTTPS or `localhost`) mandatory for `crypto.subtle` access.
|
||||
- improved composer layout for desktop and mobile
|
||||
- cleaner stacked settings cards for expiration and security options
|
||||
- compact Markdown toolbar
|
||||
- icon-only social links in the footer
|
||||
- consistent visual style shared by the app and API docs page
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ Deployment Comparison
|
||||
## 📌 Roadmap ideas
|
||||
|
||||
### Self-Hosted (This Repository) vs Cloudflare Worker
|
||||
|
||||
| Dimension | Self-Hosted | Cloudflare Worker |
|
||||
|-----------|-------------|-------------------|
|
||||
| **Infrastructure** | Your server/cPanel | Cloudflare Edge Network |
|
||||
| **Data Sovereignty** | Full control | Third-party processing |
|
||||
| **Latency** | Server location dependent | Global edge (<50ms) |
|
||||
| **Scalability** | Vertical scaling | Auto-scaling |
|
||||
| **Setup Complexity** | Database + SSL required | Zero configuration |
|
||||
| **Cost** | Hosting fees | Free tier generous |
|
||||
| **Compliance** | GDPR/HIPAA self-managed | DPA required |
|
||||
| **Availability** | Your SLA responsibility | 99.99% Cloudflare SLA |
|
||||
|
||||
**Recommendation:**
|
||||
- Choose **Self-Hosted** for: Data sovereignty, compliance requirements, learning
|
||||
- Choose **Cloudflare Worker** for: Global reach, zero maintenance, speed
|
||||
- encrypted file attachments
|
||||
- QR code for secure links
|
||||
- separate key-sharing mode
|
||||
- OpenAPI / Swagger export
|
||||
- admin-only cleanup / moderation tools
|
||||
- theme switcher
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! Areas to improve:
|
||||
Issues and pull requests are welcome.
|
||||
|
||||
- [ ] File attachments (encrypted)
|
||||
- [ ] QR code generation for sharing
|
||||
- [ ] Custom themes
|
||||
- [ ] Browser extension
|
||||
If you open a PR, try to keep these guarantees intact:
|
||||
|
||||
- client-side encryption only
|
||||
- zero-knowledge storage model
|
||||
- no accidental leakage of key material to the server
|
||||
- backward compatibility for existing shared links where possible
|
||||
|
||||
---
|
||||
|
||||
## 📜 License & Attribution
|
||||
## 📜 License
|
||||
|
||||
**License:** MIT License - See [LICENSE](LICENSE)
|
||||
|
||||
**Cryptographic Implementation:**
|
||||
- Web Crypto API W3C Specification
|
||||
- NIST SP 800-132 (PBKDF2)
|
||||
- FIPS 197 (AES)
|
||||
|
||||
**Typography:**
|
||||
- Vazirmatn by Saber Rastikerdar (Persian/Arabic script support)
|
||||
|
||||
**Inspiration:**
|
||||
- PrivateBin (PHP implementation)
|
||||
- ZeroBin (original concept)
|
||||
- CryptPad (collaborative editing)
|
||||
|
||||
---
|
||||
|
||||
**⚠️ Legal Disclaimer**
|
||||
|
||||
> THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. The authors and contributors assume no liability for data loss, security breaches, or misuse. Users are solely responsible for:
|
||||
> - Key management and storage
|
||||
> - Operational security practices
|
||||
> - Compliance with local laws and regulations
|
||||
> - Secure transmission of URLs and passwords
|
||||
|
||||
---
|
||||
|
||||
## 📊 Quick Stats
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Encryption strength | 256-bit |
|
||||
| Key derivation iterations | 100,000 |
|
||||
| IV length | 96-bit (12 bytes) |
|
||||
| Maximum message size | Limited by browser memory (~2GB) |
|
||||
| Supported languages | All Unicode (UTF-8) |
|
||||
| Database overhead | ~2x plaintext size (JSON + Base64) |
|
||||
MIT — see [`LICENSE`](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
@@ -455,4 +555,11 @@ Contributions welcome! Areas to improve:
|
||||
**TheGreatAzizi**
|
||||
|
||||
- GitHub: [@TheGreatAzizi](https://github.com/TheGreatAzizi)
|
||||
- X/Twitter: [@the_azzi](https://x.com/the_azzi)
|
||||
- X: [@the_azzi](https://x.com/the_azzi)
|
||||
- Telegram: [@luluch_code](https://t.me/luluch_code)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This project is provided **as is** without warranty of any kind. You are responsible for your own deployment security, backups, SSL/TLS configuration, database hardening, and safe sharing of URLs and passwords.
|
||||
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function h(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['SERVER_PORT'] ?? null) == 443);
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$scriptDir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/'));
|
||||
$scriptDir = ($scriptDir === '.' || $scriptDir === '/') ? '' : rtrim($scriptDir, '/');
|
||||
$baseUrl = $scheme . '://' . $host . $scriptDir;
|
||||
$assetBase = $scriptDir;
|
||||
$homeUrl = ($assetBase === '' ? '/' : $assetBase . '/');
|
||||
$docsPath = ($assetBase === '' ? '' : $assetBase) . '/api/docs';
|
||||
$styleHref = ($assetBase === '' ? '/style.css' : $assetBase . '/style.css');
|
||||
|
||||
$createExample = <<<'JSON'
|
||||
{
|
||||
"encryptedData": {
|
||||
"iv": [12, 83, 144, 221],
|
||||
"data": [167, 44, 222, 1]
|
||||
},
|
||||
"expiresIn": 86400,
|
||||
"customExpiresAt": 0,
|
||||
"burnAfterRead": true,
|
||||
"hasPassword": true
|
||||
}
|
||||
JSON;
|
||||
|
||||
$createCurlTemplate = <<<'CURL'
|
||||
curl -X POST {{BASE_URL}}/api/pastes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"encryptedData": {
|
||||
"iv": [12, 83, 144, 221],
|
||||
"data": [167, 44, 222, 1]
|
||||
},
|
||||
"expiresIn": 86400,
|
||||
"customExpiresAt": 0,
|
||||
"burnAfterRead": true,
|
||||
"hasPassword": true
|
||||
}'
|
||||
CURL;
|
||||
$createCurlExample = str_replace('{{BASE_URL}}', $baseUrl, $createCurlTemplate);
|
||||
|
||||
$createWithPathTemplate = <<<'CURL'
|
||||
curl -X POST {{BASE_URL}}/api/pastes/customPasteId123 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"ivBase64": "A1Jz4Q",
|
||||
"dataBase64": "m6xK4w8t...",
|
||||
"expiresIn": 3600,
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false
|
||||
}'
|
||||
CURL;
|
||||
$createWithPathExample = str_replace('{{BASE_URL}}', $baseUrl, $createWithPathTemplate);
|
||||
|
||||
$createResponseTemplate = <<<'JSON'
|
||||
{
|
||||
"success": true,
|
||||
"apiVersion": "1.2",
|
||||
"id": "customPasteId123",
|
||||
"expiresAt": 1770000000,
|
||||
"expiresIn": 3600,
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false,
|
||||
"url": "{{BASE_URL}}/p/customPasteId123",
|
||||
"retrieveUrl": "{{BASE_URL}}/api/pastes/customPasteId123",
|
||||
"metaUrl": "{{BASE_URL}}/api/pastes/customPasteId123/meta",
|
||||
"docsUrl": "{{BASE_URL}}/api/docs"
|
||||
}
|
||||
JSON;
|
||||
$createResponseExample = str_replace('{{BASE_URL}}', $baseUrl, $createResponseTemplate);
|
||||
|
||||
$getResponseExample = <<<'JSON'
|
||||
{
|
||||
"success": true,
|
||||
"apiVersion": "1.2",
|
||||
"id": "customPasteId123",
|
||||
"encryptedData": {
|
||||
"iv": [12, 83, 144, 221],
|
||||
"data": [167, 44, 222, 1],
|
||||
"ivBase64": "A1Jz4Q",
|
||||
"dataBase64": "m6xK4w8t..."
|
||||
},
|
||||
"data": {
|
||||
"iv": [12, 83, 144, 221],
|
||||
"data": [167, 44, 222, 1]
|
||||
},
|
||||
"burnAfterRead": false,
|
||||
"hasPassword": false,
|
||||
"created": 1769990000000,
|
||||
"expiresAt": 1770000000,
|
||||
"views": 1
|
||||
}
|
||||
JSON;
|
||||
|
||||
$optionsTemplate = <<<'JSON'
|
||||
{
|
||||
"success": true,
|
||||
"service": "Secure Pastebin API",
|
||||
"apiVersion": "1.2",
|
||||
"baseUrl": "{{BASE_URL}}",
|
||||
"docsUrl": "{{BASE_URL}}/api/docs",
|
||||
"limits": {
|
||||
"minExpirySeconds": 300,
|
||||
"maxExpirySeconds": 31536000,
|
||||
"maxEncryptedPayloadBytes": 4194304
|
||||
},
|
||||
"capabilities": [
|
||||
"create encrypted pastes",
|
||||
"create with custom id",
|
||||
"fetch encrypted pastes",
|
||||
"fetch paste metadata without consuming the payload",
|
||||
"custom expiration",
|
||||
"burn after read",
|
||||
"password-aware flag"
|
||||
]
|
||||
}
|
||||
JSON;
|
||||
$optionsExample = str_replace('{{BASE_URL}}', $baseUrl, $optionsTemplate);
|
||||
|
||||
$jsTemplate = <<<'JS'
|
||||
const payload = {
|
||||
encryptedData: {
|
||||
iv: Array.from(ivBytes),
|
||||
data: Array.from(cipherBytes)
|
||||
},
|
||||
expiresIn: 86400,
|
||||
customExpiresAt: 0,
|
||||
burnAfterRead: false,
|
||||
hasPassword: false
|
||||
};
|
||||
|
||||
const createRes = await fetch('{{BASE_URL}}/api/pastes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const created = await createRes.json();
|
||||
const readRes = await fetch(created.retrieveUrl);
|
||||
const encryptedPaste = await readRes.json();
|
||||
JS;
|
||||
$jsExample = str_replace('{{BASE_URL}}', $baseUrl, $jsTemplate);
|
||||
|
||||
$phpTemplate = <<<'PHP2'
|
||||
<?php
|
||||
|
||||
$payload = [
|
||||
'encryptedData' => [
|
||||
'iv' => [12, 83, 144, 221],
|
||||
'data' => [167, 44, 222, 1],
|
||||
],
|
||||
'expiresIn' => 86400,
|
||||
'burnAfterRead' => false,
|
||||
'hasPassword' => false,
|
||||
];
|
||||
|
||||
$ch = curl_init('{{BASE_URL}}/api/pastes');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
echo $response;
|
||||
PHP2;
|
||||
$phpExample = str_replace('{{BASE_URL}}', $baseUrl, $phpTemplate);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Secure Pastebin API Docs</title>
|
||||
<meta name="description" content="Secure Pastebin API documentation and usage guide.">
|
||||
<link rel="stylesheet" href="<?= h($styleHref) ?>">
|
||||
</head>
|
||||
<body class="api-page">
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="logo">🔐</div>
|
||||
<h1>Secure Pastebin API</h1>
|
||||
<p class="subtitle">Zero-Knowledge API for encrypted notes, subjects, and Markdown payloads</p>
|
||||
<div class="security-badges">
|
||||
<span class="badge">POST /api/pastes</span>
|
||||
<span class="badge">POST /api/pastes/{id}</span>
|
||||
<span class="badge">GET /api/pastes/{id}</span>
|
||||
<span class="badge">GET /api/pastes/{id}/meta</span>
|
||||
<span class="badge">GET /api/options</span>
|
||||
<span class="badge">GET /api/health</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card docs-intro">
|
||||
<div class="alert alert-info">
|
||||
<span>ℹ️</span>
|
||||
<div>
|
||||
<strong>Important</strong><br>
|
||||
Subject, Markdown text, and any password-derived key handling must stay on the client side. The API should receive only encrypted bytes and metadata like expiration and burn-after-read flags.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="docs-grid docs-grid-2">
|
||||
<div class="docs-panel">
|
||||
<h2>Base URL</h2>
|
||||
<div class="inline-code-block"><?= h($baseUrl) ?></div>
|
||||
</div>
|
||||
<div class="docs-panel">
|
||||
<h2>Supported capabilities</h2>
|
||||
<div class="chip-list">
|
||||
<span class="chip">create</span>
|
||||
<span class="chip">custom id</span>
|
||||
<span class="chip">retrieve</span>
|
||||
<span class="chip">metadata</span>
|
||||
<span class="chip">custom expiration</span>
|
||||
<span class="chip">burn after read</span>
|
||||
<span class="chip">password flag</span>
|
||||
<span class="chip">byte arrays</span>
|
||||
<span class="chip">base64url payloads</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Endpoint overview</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="docs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Endpoint</th>
|
||||
<th>What it does</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="endpoint endpoint-post">POST</span></td>
|
||||
<td><code>/api/pastes</code></td>
|
||||
<td>Create a new encrypted paste. The server generates a short id unless you pass one in the body.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="endpoint endpoint-post">POST</span></td>
|
||||
<td><code>/api/pastes/{id}</code></td>
|
||||
<td>Create a new encrypted paste with a custom short id in the URL path.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="endpoint endpoint-get">GET</span></td>
|
||||
<td><code>/api/pastes/{id}</code></td>
|
||||
<td>Fetch the encrypted payload plus metadata. Burn-after-read pastes are deleted after the first successful read.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="endpoint endpoint-get">GET</span></td>
|
||||
<td><code>/api/pastes/{id}/meta</code></td>
|
||||
<td>Read metadata only without consuming the encrypted payload.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="endpoint endpoint-get">GET</span></td>
|
||||
<td><code>/api/options</code></td>
|
||||
<td>Return limits, presets, supported formats, and the endpoint map.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="endpoint endpoint-get">GET</span></td>
|
||||
<td><code>/api/health</code></td>
|
||||
<td>Simple health check for uptime monitoring.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="docs-note">Legacy routes <code>/api/create</code> and <code>/api/get/{id}</code> still work for backward compatibility.</p>
|
||||
</section>
|
||||
|
||||
<section class="card docs-grid docs-grid-2">
|
||||
<div class="docs-panel">
|
||||
<h2>Create request body</h2>
|
||||
<pre class="code-block"><code><?= h($createExample) ?></code></pre>
|
||||
</div>
|
||||
<div class="docs-panel">
|
||||
<h2>Create field notes</h2>
|
||||
<ul class="docs-list">
|
||||
<li><strong>encryptedData.iv</strong> and <strong>encryptedData.data</strong> can be sent as byte arrays.</li>
|
||||
<li>You can also send <strong>iv/data</strong> at the top level instead of nesting under <code>encryptedData</code>.</li>
|
||||
<li>You can send <strong>ivBase64/dataBase64</strong> or <strong>encryptedData.ivBase64/encryptedData.dataBase64</strong> instead of byte arrays.</li>
|
||||
<li><strong>customExpiresAt</strong> is a Unix timestamp in seconds and overrides <strong>expiresIn</strong>.</li>
|
||||
<li><strong>hasPassword</strong> is a client hint only. Never send the password itself to the API.</li>
|
||||
<li>Subject and Markdown stay inside the encrypted payload so the API remains zero-knowledge.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card docs-grid docs-grid-2">
|
||||
<div class="docs-panel">
|
||||
<h2>Create example</h2>
|
||||
<pre class="code-block"><code><?= h($createCurlExample) ?></code></pre>
|
||||
</div>
|
||||
<div class="docs-panel">
|
||||
<h2>Create with custom path ID</h2>
|
||||
<pre class="code-block"><code><?= h($createWithPathExample) ?></code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card docs-grid docs-grid-2">
|
||||
<div class="docs-panel">
|
||||
<h2>Create response</h2>
|
||||
<pre class="code-block"><code><?= h($createResponseExample) ?></code></pre>
|
||||
</div>
|
||||
<div class="docs-panel">
|
||||
<h2>Read response</h2>
|
||||
<pre class="code-block"><code><?= h($getResponseExample) ?></code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card docs-grid docs-grid-2">
|
||||
<div class="docs-panel">
|
||||
<h2>Options response</h2>
|
||||
<pre class="code-block"><code><?= h($optionsExample) ?></code></pre>
|
||||
</div>
|
||||
<div class="docs-panel">
|
||||
<h2>Behavior notes</h2>
|
||||
<ul class="docs-list">
|
||||
<li><strong>url</strong> is the clean short-link base without the key fragment.</li>
|
||||
<li><strong>retrieveUrl</strong> is the API endpoint for programmatic reads.</li>
|
||||
<li><strong>metaUrl</strong> gives metadata without consuming the ciphertext.</li>
|
||||
<li>Reading a burn-after-read paste from <code>/api/pastes/{id}</code> removes it after the first successful response.</li>
|
||||
<li>Byte arrays and base64url are both returned on reads for easier client integration.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card docs-grid docs-grid-2">
|
||||
<div class="docs-panel">
|
||||
<h2>JavaScript example</h2>
|
||||
<pre class="code-block"><code><?= h($jsExample) ?></code></pre>
|
||||
</div>
|
||||
<div class="docs-panel">
|
||||
<h2>PHP example</h2>
|
||||
<pre class="code-block"><code><?= h($phpExample) ?></code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card docs-grid docs-grid-2">
|
||||
<div class="docs-panel">
|
||||
<h2>Typical integration flow</h2>
|
||||
<ol class="docs-list ordered">
|
||||
<li>Generate the AES key in the browser or in your app.</li>
|
||||
<li>Optionally derive the key from a password locally.</li>
|
||||
<li>Encrypt a JSON payload that contains subject and Markdown content.</li>
|
||||
<li>Send the encrypted bytes plus expiration settings to <code>/api/pastes</code> or <code>/api/pastes/{id}</code>.</li>
|
||||
<li>Share <code>result.url#keyFragment</code> with the recipient.</li>
|
||||
<li>The recipient calls <code>/api/pastes/{id}</code>, then decrypts locally.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="docs-panel">
|
||||
<h2>Quick links</h2>
|
||||
<div class="chip-list">
|
||||
<a class="chip" href="<?= h($homeUrl) ?>">Main app</a>
|
||||
<a class="chip" href="<?= h($docsPath) ?>">This docs page</a>
|
||||
<a class="chip" href="<?= h(($assetBase === '' ? '' : $assetBase) . '/api/options') ?>">/api/options</a>
|
||||
<a class="chip" href="<?= h(($assetBase === '' ? '' : $assetBase) . '/api/health') ?>">/api/health</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>🛡️ Zero-Knowledge Architecture • Server cannot read your data</p>
|
||||
<p class="footer-subtitle">API docs styled to match the main app.</p>
|
||||
<div class="footer-links">
|
||||
<a href="<?= h($homeUrl) ?>" title="Main app" aria-label="Main app">
|
||||
<svg width="18" height="18" fill="currentColor" viewBox="0 0 24 24"><path d="M12 3 3 9v12h18V9l-9-6Zm0 2.2 7 4.67V19h-4v-5H9v5H5V9.87l7-4.67Z"/></svg>
|
||||
</a>
|
||||
<a href="https://github.com/TheGreatAzizi/Secure-Pastebin-Self-Hosted/" target="_blank" rel="noopener" title="GitHub" aria-label="GitHub">
|
||||
<svg width="18" height="18" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.373 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.111.793-.261.793-.577v-2.173c-3.338.724-4.034-1.416-4.034-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.088-.744.084-.729.084-.729 1.205.083 1.838 1.236 1.838 1.236 1.07 1.834 2.808 1.304 3.493.997.108-.775.418-1.304.762-1.604-2.665-.304-5.467-1.333-5.467-5.93 0-1.31.469-2.381 1.236-3.221-.124-.302-.536-1.523.117-3.176 0 0 1.008-.322 3.302 1.23A11.49 11.49 0 0 1 12 5.798c1.02.005 2.047.139 3.006.404 2.292-1.552 3.3-1.23 3.3-1.23.654 1.653.242 2.874.118 3.176.768.84 1.235 1.911 1.235 3.221 0 4.609-2.806 5.624-5.479 5.92.43.372.823 1.103.823 2.223v3.293c0 .319.192.69.802.576C20.565 21.796 24 17.3 24 12 24 5.373 18.627 0 12 0Z"/></svg>
|
||||
</a>
|
||||
<a href="https://x.com/the_azzi" target="_blank" rel="noopener" title="X" aria-label="X">
|
||||
<svg width="18" height="18" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M18.901 1.153h3.68l-8.04 9.19 9.458 12.504h-7.406l-5.8-7.584-6.637 7.584H.476l8.601-9.83L0 1.154h7.594l5.243 6.932 6.064-6.932Zm-1.293 19.487h2.039L6.486 3.24H4.298L17.608 20.64Z"/></svg>
|
||||
</a>
|
||||
<a href="https://t.me/luluch_code" target="_blank" rel="noopener" title="Telegram" aria-label="Telegram">
|
||||
<svg width="18" height="18" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.993 15.674 9.62 20.92c.534 0 .765-.229 1.042-.504l2.502-2.394 5.185 3.796c.951.524 1.624.248 1.881-.875l3.408-15.97.001-.001c.302-1.41-.51-1.962-1.437-1.617L2.155 11.11c-1.367.534-1.346 1.296-.233 1.64l5.115 1.595L18.86 6.88c.556-.368 1.062-.164.646.204"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<a class="footer-doc-link" href="<?= h($docsPath) ?>">API Docs</a>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+155
-113
@@ -6,9 +6,6 @@
|
||||
<title>Secure Pastebin - End-to-End Encrypted Message Sharing</title>
|
||||
<meta name="description" content="Share sensitive messages securely with AES-256 encryption. Zero-knowledge architecture, password protection, self-destructing messages.">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Vazirmatn:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -33,77 +30,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="createView" class="card">
|
||||
<div class="alert alert-warning">
|
||||
<span>⚠️</span>
|
||||
<div>
|
||||
<strong>Important:</strong> The encryption key is stored only in the URL fragment (after #).
|
||||
If you lose the URL, the data is permanently lost. We cannot recover it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea id="content" placeholder="Enter your secret message here... (Supports English, Persian, Arabic, Hebrew, and all languages)" dir="auto"></textarea>
|
||||
|
||||
<div class="password-section">
|
||||
<label class="password-toggle">
|
||||
<input type="checkbox" id="enablePassword" onchange="togglePassword()">
|
||||
<span>🔐 Protect with Password (Optional)</span>
|
||||
</label>
|
||||
<div id="passwordWrapper" class="password-input-wrapper">
|
||||
<label>Enter Password:</label>
|
||||
<input type="password" id="passwordInput" placeholder="Minimum 4 characters">
|
||||
<small style="color: var(--text-muted); display: block; margin-top: 8px;">
|
||||
This password will be required to decrypt the message. Share it separately.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-grid">
|
||||
<div class="form-group">
|
||||
<label>⏱️ Expiration</label>
|
||||
<select id="expiresIn">
|
||||
<option value="3600">1 Hour</option>
|
||||
<option value="86400" selected>1 Day</option>
|
||||
<option value="604800">1 Week</option>
|
||||
<option value="2592000">30 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>🔥 Security Mode</label>
|
||||
<label class="checkbox-wrapper">
|
||||
<input type="checkbox" id="burnAfterRead">
|
||||
<span>Burn after reading</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="createBtn" class="btn" onclick="createPaste()">
|
||||
<span id="btnText">🔐 Encrypt & Save</span>
|
||||
</button>
|
||||
|
||||
<div id="resultBox" class="result-box">
|
||||
<div class="alert alert-success" style="margin-bottom: 12px;">
|
||||
<span>✅</span>
|
||||
<strong>Successfully encrypted!</strong>
|
||||
</div>
|
||||
<div id="passwordNotice" class="alert alert-info" style="display: none; margin-bottom: 12px;">
|
||||
<span>🔑</span>
|
||||
<div>
|
||||
<strong>Password Protected!</strong><br>
|
||||
Remember to share the password separately. It is NOT in the URL.
|
||||
</div>
|
||||
</div>
|
||||
<div class="url-container">
|
||||
<input type="text" id="shareUrl" class="url-input" readonly>
|
||||
<button class="btn btn-copy" onclick="copyUrl()">📋 Copy</button>
|
||||
</div>
|
||||
<div class="meta-info">
|
||||
⏰ Expires: <span id="expiryTime"></span> • 🔑 Key never leaves your browser
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="passwordPrompt" class="card password-prompt">
|
||||
<div class="alert alert-info" style="margin-bottom: 20px;">
|
||||
<span>🔐</span>
|
||||
@@ -122,17 +48,154 @@
|
||||
<div id="decryptView" class="card decrypt-view">
|
||||
<div id="burnNotice" class="burn-warning" style="display: none;">
|
||||
<span>🔥</span>
|
||||
<span>This message will be permanently deleted after you view it!</span>
|
||||
<span>This message will be permanently deleted after you view it.</span>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-bottom: 12px;">🔓 Decrypted Content</h3>
|
||||
|
||||
<div class="decrypt-header">
|
||||
<div>
|
||||
<h3 class="section-title">🔓 Decrypted Content</h3>
|
||||
<div id="decryptedSubjectWrapper" class="decrypted-subject-wrapper" style="display: none;">
|
||||
<span class="subject-label">Subject</span>
|
||||
<div id="decryptedSubject" class="subject-display" dir="auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="copyDecryptedBtn" class="btn btn-copy btn-copy-content" type="button" onclick="copyDecryptedContent()">📋 Copy Text</button>
|
||||
</div>
|
||||
|
||||
<div id="decryptedContent" class="content-box" dir="auto"></div>
|
||||
|
||||
<button class="btn btn-secondary" onclick="window.location.href='/'" style="margin-top: 16px;">
|
||||
|
||||
<button class="btn btn-secondary" onclick="window.location.href='/'" style="margin-top: 18px;">
|
||||
📝 Create New Message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="createView" class="card">
|
||||
<div class="alert alert-warning">
|
||||
<span>⚠️</span>
|
||||
<div>
|
||||
<strong>Important:</strong> The encryption key is stored only in the URL fragment (after #).
|
||||
If you lose the URL, the data is permanently lost. We cannot recover it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group-full">
|
||||
<label for="subject">📝 Subject</label>
|
||||
<input type="text" id="subject" placeholder="Add a subject for this secure message (optional)" maxlength="120" dir="auto">
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group-full">
|
||||
<div class="editor-shell">
|
||||
<div class="editor-shell-head">
|
||||
<label for="content">✍️ Secret Message</label>
|
||||
<div class="editor-toolbar" role="toolbar" aria-label="Formatting toolbar">
|
||||
<button type="button" class="toolbar-btn" data-action="bold" title="Bold" aria-label="Bold"><strong>B</strong></button>
|
||||
<button type="button" class="toolbar-btn" data-action="italic" title="Italic" aria-label="Italic"><em>I</em></button>
|
||||
<button type="button" class="toolbar-btn" data-action="strike" title="Strikethrough" aria-label="Strikethrough"><span class="toolbar-strike">S</span></button>
|
||||
<button type="button" class="toolbar-btn" data-action="heading" title="Heading" aria-label="Heading">H</button>
|
||||
<button type="button" class="toolbar-btn" data-action="quote" title="Quote" aria-label="Quote">❝</button>
|
||||
<button type="button" class="toolbar-btn" data-action="bullet" title="Bullet list" aria-label="Bullet list">•</button>
|
||||
<button type="button" class="toolbar-btn" data-action="numbered" title="Numbered list" aria-label="Numbered list">1.</button>
|
||||
<button type="button" class="toolbar-btn" data-action="link" title="Link" aria-label="Link">🔗</button>
|
||||
<button type="button" class="toolbar-btn" data-action="code" title="Inline code" aria-label="Inline code"></></button>
|
||||
<button type="button" class="toolbar-btn" data-action="codeblock" title="Code block" aria-label="Code block">{ }</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="content" placeholder="Enter your secret message here... Supports Markdown too (#, **bold**, lists, code blocks, links)" dir="auto"></textarea>
|
||||
</div>
|
||||
<div class="input-help">Use the toolbar to insert Markdown quickly. The raw text is what gets encrypted, and the Markdown is rendered only after decryption.</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="form-group option-panel expiry-panel full-width-option">
|
||||
<label for="expiresIn">⏱️ Expiration</label>
|
||||
<select id="expiresIn">
|
||||
<option value="3600">1 Hour</option>
|
||||
<option value="86400" selected>1 Day</option>
|
||||
<option value="604800">1 Week</option>
|
||||
<option value="2592000">30 Days</option>
|
||||
<option value="custom">Custom date & time</option>
|
||||
</select>
|
||||
<div id="customExpiryWrapper" class="custom-expiry-wrapper">
|
||||
<input type="datetime-local" id="customExpiry">
|
||||
<small>Choose the exact expiration moment in your local timezone.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="security-stack">
|
||||
<div class="option-panel option-panel-password">
|
||||
<label class="feature-toggle">
|
||||
<input type="checkbox" id="enablePassword" onchange="togglePassword()">
|
||||
<span class="feature-toggle-main">
|
||||
<span class="feature-icon">🔐</span>
|
||||
<span class="feature-copy">
|
||||
<span class="feature-title">Protect with password</span>
|
||||
<span class="feature-description">Add a second secret that you share separately with the recipient.</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="switch-ui" aria-hidden="true"></span>
|
||||
</label>
|
||||
<div id="passwordWrapper" class="password-input-wrapper">
|
||||
<label for="passwordInput">Enter Password</label>
|
||||
<input type="password" id="passwordInput" placeholder="Minimum 4 characters">
|
||||
<div id="passwordStrength" class="password-strength" data-strength="empty">
|
||||
<div class="password-strength-bar">
|
||||
<div id="passwordStrengthFill" class="password-strength-fill"></div>
|
||||
</div>
|
||||
<div id="passwordStrengthLabel" class="password-strength-label">Password strength will appear here</div>
|
||||
</div>
|
||||
<small class="helper-text">This password will be required to decrypt the message. Share it separately.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option-panel option-panel-burn">
|
||||
<label class="feature-toggle">
|
||||
<input type="checkbox" id="burnAfterRead">
|
||||
<span class="feature-toggle-main">
|
||||
<span class="feature-icon">🔥</span>
|
||||
<span class="feature-copy">
|
||||
<span class="feature-title">Burn after reading</span>
|
||||
<span class="feature-description">Delete the encrypted paste automatically after the first successful view.</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="switch-ui" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="createBtn" class="btn" onclick="createPaste()">
|
||||
<span id="btnText">🔐 Encrypt & Save</span>
|
||||
</button>
|
||||
|
||||
<div id="resultBox" class="result-box">
|
||||
<div class="alert alert-success compact-alert">
|
||||
<span>✅</span>
|
||||
<strong>Successfully encrypted.</strong>
|
||||
</div>
|
||||
<div id="passwordNotice" class="alert alert-info compact-alert" style="display: none;">
|
||||
<span>🔑</span>
|
||||
<div>
|
||||
<strong>Password Protected</strong><br>
|
||||
Remember to share the password separately. It is not in the URL.
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-link-group">
|
||||
<label for="shareUrl">🔗 Full secure link</label>
|
||||
<div class="url-container">
|
||||
<input type="text" id="shareUrl" class="url-input" readonly>
|
||||
<div class="url-actions">
|
||||
<button id="copyUrlBtn" class="btn btn-copy" type="button" onclick="copyUrl()">📋 Copy</button>
|
||||
<button id="copyShortUrlBtn" class="btn btn-copy" type="button" onclick="copyShortUrl()">⚡ Copy short</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="helper-copy">The short link uses the compact format and still keeps the key in the URL fragment.</div>
|
||||
</div>
|
||||
<div class="meta-info">
|
||||
⏰ Expires: <span id="expiryTime"></span> • 🔑 Key never leaves your browser • ✨ Full + short share links
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card how-it-works">
|
||||
<h2>🔍 How It Works</h2>
|
||||
<div class="steps">
|
||||
@@ -168,11 +231,11 @@
|
||||
<div class="step-number">5</div>
|
||||
<div class="step-content">
|
||||
<h3>Self-Destruction</h3>
|
||||
<p>Choose "Burn after reading" to automatically delete the message after first view, or set an expiration time (1 hour to 30 days).</p>
|
||||
<p>Choose burn-after-read to automatically delete the message after first view, or set a preset expiration or exact custom date and time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tech-specs">
|
||||
<h3>🛡️ Technical Specifications</h3>
|
||||
<div class="specs-grid">
|
||||
@@ -183,48 +246,27 @@
|
||||
<div class="spec-item">No Server-Side Logs</div>
|
||||
<div class="spec-item">MySQL Database Storage</div>
|
||||
<div class="spec-item">Web Crypto API (Native)</div>
|
||||
<div class="spec-item">No Registration Required</div>
|
||||
<div class="spec-item">Custom Expiration Support</div>
|
||||
<div class="spec-item">Markdown Rendering After Decryption</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>🛡️ Zero-Knowledge Architecture • Server cannot read your data</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 8px; color: var(--text-muted);">
|
||||
Built with privacy in mind. No tracking. No analytics. Open source.
|
||||
</p>
|
||||
|
||||
<p class="footer-subtitle">Built with privacy in mind. No tracking. No analytics. Open source.</p>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/TheGreatAzizi/Secure-Pastebin-Self-Hosted/" target="_blank" rel="noopener">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
<a href="https://github.com/TheGreatAzizi/Secure-Pastebin-Self-Hosted/" target="_blank" rel="noopener" title="GitHub" aria-label="GitHub">
|
||||
<svg width="18" height="18" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.744.083-.729.083-.729 1.205.084 1.839 1.236 1.839 1.236 1.07 1.834 2.807 1.304 3.492.997.108-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.958-.266 1.983-.399 3.003-.404 1.02.005 2.045.138 3.005.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
</a>
|
||||
<a href="https://x.com/the_azzi" target="_blank" rel="noopener">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
@the_azzi
|
||||
<a href="https://x.com/the_azzi" target="_blank" rel="noopener" title="X" aria-label="X">
|
||||
<svg width="18" height="18" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M18.901 1.153h3.68l-8.04 9.19 9.458 12.504h-7.406l-5.8-7.584-6.637 7.584H.476l8.601-9.83L0 1.154h7.594l5.243 6.932 6.064-6.932Zm-1.293 19.487h2.039L6.486 3.24H4.298L17.608 20.64Z"/></svg>
|
||||
</a>
|
||||
<a href="https://t.me/luluch_code" target="_blank" rel="noopener" title="Telegram" aria-label="Telegram">
|
||||
<svg width="18" height="18" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.993 15.674 9.62 20.92c.534 0 .765-.229 1.042-.504l2.502-2.394 5.185 3.796c.951.524 1.624.248 1.881-.875l3.408-15.97.001-.001c.302-1.41-.51-1.962-1.437-1.617L2.155 11.11c-1.367.534-1.346 1.296-.233 1.64l5.115 1.595L18.86 6.88c.556-.368 1.062-.164.646.204"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="internet-for-all">
|
||||
<div class="hashtag">#InternetForAll</div>
|
||||
<div class="rights-text">
|
||||
<strong>Internet access is a fundamental human right.</strong><br>
|
||||
Restricting internet access violates human rights and limits freedom of expression,
|
||||
access to information, and the ability to communicate securely.
|
||||
We believe in <strong>free, open, and secure internet for everyone</strong> —
|
||||
regardless of borders, politics, or censorship.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tech-stack">
|
||||
<span>PHP</span>
|
||||
<span>•</span>
|
||||
<span>MySQL</span>
|
||||
<span>•</span>
|
||||
<span>Web Crypto API</span>
|
||||
<span>•</span>
|
||||
<span>AES-256-GCM</span>
|
||||
</div>
|
||||
<a class="footer-doc-link" href="/api/docs">API Docs</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,169 +1,570 @@
|
||||
<?php
|
||||
// Secure Pastebin API - cPanel/PHP Version
|
||||
header('Content-Type: application/json');
|
||||
// Secure Pastebin - router + API
|
||||
|
||||
// Database Config
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_USER', ' --------- ');
|
||||
define('DB_PASS', ' --------- ');
|
||||
define('DB_NAME', ' --------- ');
|
||||
declare(strict_types=1);
|
||||
|
||||
const DB_HOST = 'localhost';
|
||||
const DB_USER = ' --------- ';
|
||||
const DB_PASS = ' --------- ';
|
||||
const DB_NAME = ' --------- ';
|
||||
|
||||
// CORS
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
const MIN_EXPIRY_SECONDS = 300;
|
||||
const MAX_EXPIRY_SECONDS = 31536000;
|
||||
const MAX_DATA_BYTES = 1024 * 1024 * 4;
|
||||
const API_VERSION = '1.2';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$scriptDir = normalizeScriptDir(dirname($_SERVER['SCRIPT_NAME'] ?? '/'));
|
||||
|
||||
// DB Connection
|
||||
try {
|
||||
$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
} catch(PDOException $e) {
|
||||
http_response_code(500);
|
||||
die(json_encode(array('error' => 'Database connection failed')));
|
||||
}
|
||||
|
||||
// Clean expired entries
|
||||
$pdo->exec("DELETE FROM pastes WHERE expires_at < " . time());
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// Remove subdirectory from path if exists
|
||||
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
||||
if ($scriptDir !== '/' && strpos($path, $scriptDir) === 0) {
|
||||
$path = substr($path, strlen($scriptDir));
|
||||
}
|
||||
if (empty($path)) $path = '/';
|
||||
|
||||
// API Routes
|
||||
if ($path === '/api/create' && $method === 'POST') {
|
||||
handleCreate($pdo);
|
||||
exit;
|
||||
$path = $path === '' ? '/' : $path;
|
||||
}
|
||||
|
||||
if (preg_match('/^\/api\/get\/(.+)$/', $path, $matches) && $method === 'GET') {
|
||||
handleGet($pdo, $matches[1]);
|
||||
exit;
|
||||
$path = normalizeRoutePath($path);
|
||||
|
||||
$apiPaths = [
|
||||
'/api/create',
|
||||
'/api/options',
|
||||
'/api/health',
|
||||
'/api/docs',
|
||||
];
|
||||
$startsWithApi = strpos($path, '/api/') === 0;
|
||||
|
||||
if ($startsWithApi || in_array($path, $apiPaths, true)) {
|
||||
sendCorsHeaders();
|
||||
if ($method === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Serve static files
|
||||
if ($path === '/' || $path === '/index.html') {
|
||||
if ($path === '/api/docs' || $path === '/api-docs.php') {
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
readfile(__DIR__ . '/index.html');
|
||||
require __DIR__ . '/api-docs.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/style.css') {
|
||||
header('Content-Type: text/css');
|
||||
readfile(__DIR__ . '/style.css');
|
||||
exit;
|
||||
serveFile(__DIR__ . '/style.css', 'text/css; charset=utf-8');
|
||||
}
|
||||
|
||||
if ($path === '/script.js') {
|
||||
header('Content-Type: application/javascript');
|
||||
readfile(__DIR__ . '/script.js');
|
||||
exit;
|
||||
serveFile(__DIR__ . '/script.js', 'application/javascript; charset=utf-8');
|
||||
}
|
||||
|
||||
if ($path === '/favicon.svg') {
|
||||
header('Content-Type: image/svg+xml');
|
||||
echo '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#ec4899"/></linearGradient></defs><rect width="100" height="100" rx="20" fill="url(#g)"/><path d="M50 25c-8 0-14 6-14 14v6h-4c-3 0-6 3-6 6v24c0 3 3 6 6 6h36c3 0 6-3 6-6v-24c0-3-3-6-6-6h-4v-6c0-8-6-14-14-14zm0 6c4 0 8 4 8 8v6H42v-6c0-4 4-8 8-8z" fill="white"/></svg>';
|
||||
header('Content-Type: image/svg+xml; charset=utf-8');
|
||||
echo '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="24" fill="url(#g)"/><path d="M50 24c-8.6 0-15 6.4-15 15v7h-3c-3.3 0-6 2.7-6 6v22c0 3.3 2.7 6 6 6h36c3.3 0 6-2.7 6-6V52c0-3.3-2.7-6-6-6h-3v-7c0-8.6-6.4-15-15-15Zm0 7c4.6 0 8 3.4 8 8v7H42v-7c0-4.6 3.4-8 8-8Z" fill="white"/></svg>';
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
echo json_encode(array('error' => 'Not found'));
|
||||
if ($path === '/' || $path === '/index.html' || preg_match('#^/p/[^/]+$#', $path)) {
|
||||
serveFile(__DIR__ . '/index.html', 'text/html; charset=utf-8');
|
||||
}
|
||||
|
||||
function handleCreate($pdo) {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$input) {
|
||||
http_response_code(400);
|
||||
echo json_encode(array('error' => 'Invalid JSON'));
|
||||
return;
|
||||
if ($path === '/api/options' && $method === 'GET') {
|
||||
jsonResponse(buildApiOptions());
|
||||
}
|
||||
|
||||
if ($path === '/api/health' && $method === 'GET') {
|
||||
$pdo = getPdo();
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'status' => 'ok',
|
||||
'apiVersion' => API_VERSION,
|
||||
'database' => $pdo ? 'connected' : 'unavailable',
|
||||
'time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (($path === '/api/pastes' || $path === '/api/create') && $method === 'POST') {
|
||||
$pdo = requirePdo();
|
||||
cleanupExpired($pdo);
|
||||
handleCreate($pdo);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && preg_match('#^/api/pastes/([A-Za-z0-9_-]+)$#', $path, $matches)) {
|
||||
$pdo = requirePdo();
|
||||
cleanupExpired($pdo);
|
||||
handleCreate($pdo, $matches[1]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && preg_match('#^/api/pastes/([A-Za-z0-9_-]+)/(meta)$#', $path, $matches)) {
|
||||
$pdo = requirePdo();
|
||||
cleanupExpired($pdo);
|
||||
handleMeta($pdo, $matches[1]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && preg_match('#^/api/pastes/([A-Za-z0-9_-]+)$#', $path, $matches)) {
|
||||
$pdo = requirePdo();
|
||||
cleanupExpired($pdo);
|
||||
handleGet($pdo, $matches[1]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && preg_match('#^/api/get/([A-Za-z0-9_-]+)$#', $path, $matches)) {
|
||||
$pdo = requirePdo();
|
||||
cleanupExpired($pdo);
|
||||
handleGet($pdo, $matches[1]);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'error' => 'Not found',
|
||||
'apiVersion' => API_VERSION,
|
||||
'available' => [
|
||||
'GET /api/health',
|
||||
'GET /api/options',
|
||||
'GET /api/docs',
|
||||
'POST /api/pastes',
|
||||
'POST /api/pastes/{id}',
|
||||
'GET /api/pastes/{id}',
|
||||
'GET /api/pastes/{id}/meta',
|
||||
'POST /api/create',
|
||||
'GET /api/get/{id}',
|
||||
],
|
||||
], 404);
|
||||
|
||||
function normalizeScriptDir(string $dir): string
|
||||
{
|
||||
$dir = str_replace('\\', '/', $dir);
|
||||
if ($dir === '' || $dir === '.') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
$id = isset($input['id']) ? $input['id'] : '';
|
||||
$encryptedData = isset($input['encryptedData']) ? $input['encryptedData'] : null;
|
||||
$expiresIn = min(intval(isset($input['expiresIn']) ? $input['expiresIn'] : 3600), 2592000);
|
||||
if ($dir[0] !== '/') {
|
||||
$dir = '/' . $dir;
|
||||
}
|
||||
return rtrim($dir, '/') ?: '/';
|
||||
}
|
||||
|
||||
function normalizeRoutePath(string $path): string
|
||||
{
|
||||
if ($path === '') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
$path = preg_replace('#/+#', '/', $path) ?: '/';
|
||||
if ($path !== '/' && str_ends_with($path, '/')) {
|
||||
$path = rtrim($path, '/');
|
||||
$path = $path === '' ? '/' : $path;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
function sendCorsHeaders(): void
|
||||
{
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
}
|
||||
|
||||
function serveFile(string $path, string $contentType): void
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
http_response_code(404);
|
||||
echo 'Not found';
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: ' . $contentType);
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
function jsonResponse(array $data, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function getPdo(): ?PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
static $attempted = false;
|
||||
|
||||
if ($attempted) {
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
$attempted = true;
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
|
||||
DB_USER,
|
||||
DB_PASS,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
$pdo = null;
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function requirePdo(): PDO
|
||||
{
|
||||
$pdo = getPdo();
|
||||
if (!$pdo) {
|
||||
jsonResponse(['error' => 'Database connection failed', 'apiVersion' => API_VERSION], 500);
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function cleanupExpired(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->prepare('DELETE FROM pastes WHERE expires_at < ?');
|
||||
$stmt->execute([time()]);
|
||||
}
|
||||
|
||||
function isValidPasteId(string $id): bool
|
||||
{
|
||||
return (bool) preg_match('/^(?:[a-f0-9]{32}|[A-Za-z0-9_-]{16})$/', $id);
|
||||
}
|
||||
|
||||
function readJsonInput(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
jsonResponse(['error' => 'Invalid JSON body', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
function normalizeByteList(array $list, string $fieldName): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($list as $value) {
|
||||
if (!is_numeric($value)) {
|
||||
jsonResponse(['error' => 'Invalid byte value in ' . $fieldName, 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
$int = (int) $value;
|
||||
if ($int < 0 || $int > 255) {
|
||||
jsonResponse(['error' => 'Byte values in ' . $fieldName . ' must be between 0 and 255', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
$normalized[] = $int;
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
function normalizeEncryptedData(array $encryptedData): array
|
||||
{
|
||||
if (isset($encryptedData['iv'], $encryptedData['data']) && is_array($encryptedData['iv']) && is_array($encryptedData['data'])) {
|
||||
return [
|
||||
'iv' => normalizeByteList($encryptedData['iv'], 'encryptedData.iv'),
|
||||
'data' => normalizeByteList($encryptedData['data'], 'encryptedData.data'),
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($encryptedData['ivBase64'], $encryptedData['dataBase64']) && is_string($encryptedData['ivBase64']) && is_string($encryptedData['dataBase64'])) {
|
||||
$ivBytes = array_values(unpack('C*', base64urlDecode($encryptedData['ivBase64'])) ?: []);
|
||||
$dataBytes = array_values(unpack('C*', base64urlDecode($encryptedData['dataBase64'])) ?: []);
|
||||
return ['iv' => $ivBytes, 'data' => $dataBytes];
|
||||
}
|
||||
|
||||
if (isset($encryptedData['ivBase64url'], $encryptedData['dataBase64url']) && is_string($encryptedData['ivBase64url']) && is_string($encryptedData['dataBase64url'])) {
|
||||
$ivBytes = array_values(unpack('C*', base64urlDecode($encryptedData['ivBase64url'])) ?: []);
|
||||
$dataBytes = array_values(unpack('C*', base64urlDecode($encryptedData['dataBase64url'])) ?: []);
|
||||
return ['iv' => $ivBytes, 'data' => $dataBytes];
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Invalid encrypted data format', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
function parseEncryptedPayloadInput(array $input): array
|
||||
{
|
||||
if (isset($input['encryptedData']) && is_array($input['encryptedData'])) {
|
||||
return normalizeEncryptedData($input['encryptedData']);
|
||||
}
|
||||
|
||||
if (isset($input['iv'], $input['data']) && is_array($input['iv']) && is_array($input['data'])) {
|
||||
return normalizeEncryptedData([
|
||||
'iv' => $input['iv'],
|
||||
'data' => $input['data'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($input['ivBase64'], $input['dataBase64']) && is_string($input['ivBase64']) && is_string($input['dataBase64'])) {
|
||||
return normalizeEncryptedData([
|
||||
'ivBase64' => $input['ivBase64'],
|
||||
'dataBase64' => $input['dataBase64'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($input['ivBase64url'], $input['dataBase64url']) && is_string($input['ivBase64url']) && is_string($input['dataBase64url'])) {
|
||||
return normalizeEncryptedData([
|
||||
'ivBase64url' => $input['ivBase64url'],
|
||||
'dataBase64url' => $input['dataBase64url'],
|
||||
]);
|
||||
}
|
||||
|
||||
jsonResponse(['error' => 'Missing encrypted data. Provide encryptedData, iv/data, or ivBase64/dataBase64', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
function base64urlDecode(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$value = strtr($value, '-_', '+/');
|
||||
$padding = strlen($value) % 4;
|
||||
if ($padding > 0) {
|
||||
$value .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
if ($decoded === false) {
|
||||
jsonResponse(['error' => 'Invalid base64url payload', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
function base64urlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
function buildBaseUrl(): string
|
||||
{
|
||||
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['SERVER_PORT'] ?? null) == 443);
|
||||
$scheme = $isHttps ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$scriptDir = normalizeScriptDir(dirname($_SERVER['SCRIPT_NAME'] ?? '/'));
|
||||
|
||||
return $scheme . '://' . $host . ($scriptDir === '/' ? '' : $scriptDir);
|
||||
}
|
||||
|
||||
function buildApiOptions(): array
|
||||
{
|
||||
$baseUrl = buildBaseUrl();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'service' => 'Secure Pastebin API',
|
||||
'apiVersion' => API_VERSION,
|
||||
'baseUrl' => $baseUrl,
|
||||
'docsUrl' => $baseUrl . '/api/docs',
|
||||
'limits' => [
|
||||
'minExpirySeconds' => MIN_EXPIRY_SECONDS,
|
||||
'maxExpirySeconds' => MAX_EXPIRY_SECONDS,
|
||||
'maxEncryptedPayloadBytes' => MAX_DATA_BYTES,
|
||||
],
|
||||
'presets' => [
|
||||
['label' => '5 minutes', 'seconds' => 300],
|
||||
['label' => '1 hour', 'seconds' => 3600],
|
||||
['label' => '1 day', 'seconds' => 86400],
|
||||
['label' => '1 week', 'seconds' => 604800],
|
||||
['label' => '30 days', 'seconds' => 2592000],
|
||||
],
|
||||
'capabilities' => [
|
||||
'create encrypted pastes',
|
||||
'fetch encrypted pastes',
|
||||
'fetch paste metadata without consuming the payload',
|
||||
'clean short share links',
|
||||
'custom expiration',
|
||||
'burn after read',
|
||||
'password-aware flag',
|
||||
'custom IDs in the request body or path',
|
||||
'byte-array and base64url payload formats',
|
||||
],
|
||||
'createRequestFormats' => [
|
||||
'body.encryptedData.iv + body.encryptedData.data',
|
||||
'body.iv + body.data',
|
||||
'body.encryptedData.ivBase64 + body.encryptedData.dataBase64',
|
||||
'body.ivBase64 + body.dataBase64',
|
||||
],
|
||||
'endpoints' => [
|
||||
'docs' => '/api/docs',
|
||||
'health' => '/api/health',
|
||||
'options' => '/api/options',
|
||||
'create' => '/api/pastes',
|
||||
'createWithCustomId' => '/api/pastes/{id}',
|
||||
'retrieve' => '/api/pastes/{id}',
|
||||
'meta' => '/api/pastes/{id}/meta',
|
||||
'legacyCreate' => '/api/create',
|
||||
'legacyGet' => '/api/get/{id}',
|
||||
],
|
||||
'notes' => [
|
||||
'Encrypt on the client side. The API stores ciphertext only.',
|
||||
'Password material should never be sent to the server.',
|
||||
'Markdown and subject live inside the encrypted payload.',
|
||||
'GET /api/pastes/{id} deletes a burn-after-read paste immediately after returning it once.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function calculateExpiry(array $input): array
|
||||
{
|
||||
$requestedExpiresIn = isset($input['expiresIn']) ? (int) $input['expiresIn'] : 86400;
|
||||
$customExpiresAt = isset($input['customExpiresAt']) ? (int) $input['customExpiresAt'] : 0;
|
||||
$now = time();
|
||||
|
||||
if ($customExpiresAt > 0) {
|
||||
if ($customExpiresAt < ($now + MIN_EXPIRY_SECONDS)) {
|
||||
jsonResponse(['error' => 'Custom expiration must be at least 5 minutes from now', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
if ($customExpiresAt > ($now + MAX_EXPIRY_SECONDS)) {
|
||||
jsonResponse(['error' => 'Custom expiration cannot be more than 365 days from now', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
$expiresAt = $customExpiresAt;
|
||||
} else {
|
||||
$expiresIn = max(MIN_EXPIRY_SECONDS, min($requestedExpiresIn, MAX_EXPIRY_SECONDS));
|
||||
$expiresAt = $now + $expiresIn;
|
||||
}
|
||||
|
||||
return ['expiresAt' => $expiresAt, 'expiresIn' => $expiresAt - $now];
|
||||
}
|
||||
|
||||
function handleCreate(PDO $pdo, ?string $routeId = null): void
|
||||
{
|
||||
$input = readJsonInput();
|
||||
|
||||
$bodyId = isset($input['id']) && is_string($input['id']) && $input['id'] !== '' ? $input['id'] : null;
|
||||
$id = $routeId ?? $bodyId ?? generateFallbackId();
|
||||
|
||||
if (!isValidPasteId($id)) {
|
||||
jsonResponse(['error' => 'Invalid ID format', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
if ($routeId !== null && $bodyId !== null && $routeId !== $bodyId) {
|
||||
jsonResponse(['error' => 'Route ID and body ID do not match', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
$encryptedData = parseEncryptedPayloadInput($input);
|
||||
$payloadBytes = count($encryptedData['iv']) + count($encryptedData['data']);
|
||||
if ($payloadBytes > MAX_DATA_BYTES) {
|
||||
jsonResponse(['error' => 'Encrypted payload is too large', 'apiVersion' => API_VERSION], 413);
|
||||
}
|
||||
|
||||
$expiry = calculateExpiry($input);
|
||||
$burnAfterRead = !empty($input['burnAfterRead']);
|
||||
$hasPassword = !empty($input['hasPassword']);
|
||||
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $id)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(array('error' => 'Invalid ID format'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$encryptedData || !isset($encryptedData['iv']) || !isset($encryptedData['data'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(array('error' => 'Invalid encrypted data format'));
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_encode(array('iv' => $encryptedData['iv'], 'data' => $encryptedData['data']));
|
||||
$expiresAt = time() + $expiresIn;
|
||||
|
||||
$storedData = json_encode($encryptedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("INSERT INTO pastes (id, data, created_at, expires_at, burn_after_read, has_password, views) VALUES (?, ?, ?, ?, ?, ?, 0)");
|
||||
$stmt->execute(array($id, $data, time() * 1000, $expiresAt, $burnAfterRead ? 1 : 0, $hasPassword ? 1 : 0));
|
||||
|
||||
echo json_encode(array(
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'expiresIn' => $expiresIn,
|
||||
'hasPassword' => $hasPassword,
|
||||
'url' => 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] . '/../#' . $id
|
||||
));
|
||||
} catch(PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(array('error' => 'Failed to save paste: ' . $e->getMessage()));
|
||||
$stmt = $pdo->prepare('INSERT INTO pastes (id, data, created_at, expires_at, burn_after_read, has_password, views) VALUES (?, ?, ?, ?, ?, ?, 0)');
|
||||
$stmt->execute([$id, $storedData, time() * 1000, $expiry['expiresAt'], $burnAfterRead ? 1 : 0, $hasPassword ? 1 : 0]);
|
||||
} catch (PDOException $e) {
|
||||
$message = $e->getCode() === '23000' ? 'A paste with this ID already exists' : 'Failed to save paste';
|
||||
jsonResponse(['error' => $message, 'apiVersion' => API_VERSION], 500);
|
||||
}
|
||||
|
||||
$baseUrl = buildBaseUrl();
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'apiVersion' => API_VERSION,
|
||||
'id' => $id,
|
||||
'expiresAt' => $expiry['expiresAt'],
|
||||
'expiresIn' => $expiry['expiresIn'],
|
||||
'burnAfterRead' => $burnAfterRead,
|
||||
'hasPassword' => $hasPassword,
|
||||
'url' => $baseUrl . '/p/' . rawurlencode($id),
|
||||
'retrieveUrl' => $baseUrl . '/api/pastes/' . rawurlencode($id),
|
||||
'metaUrl' => $baseUrl . '/api/pastes/' . rawurlencode($id) . '/meta',
|
||||
'docsUrl' => $baseUrl . '/api/docs',
|
||||
], 201);
|
||||
}
|
||||
|
||||
function handleGet($pdo, $id) {
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $id)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(array('error' => 'Invalid ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT * FROM pastes WHERE id = ? AND expires_at > ?");
|
||||
$stmt->execute(array($id, time()));
|
||||
$paste = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$paste) {
|
||||
http_response_code(404);
|
||||
echo json_encode(array('error' => 'Paste not found or expired'));
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($paste['data'], true);
|
||||
|
||||
if ($paste['burn_after_read']) {
|
||||
$delStmt = $pdo->prepare("DELETE FROM pastes WHERE id = ?");
|
||||
$delStmt->execute(array($id));
|
||||
} else {
|
||||
$updStmt = $pdo->prepare("UPDATE pastes SET views = views + 1 WHERE id = ?");
|
||||
$updStmt->execute(array($id));
|
||||
}
|
||||
|
||||
echo json_encode(array(
|
||||
'data' => $data,
|
||||
'burnAfterRead' => (bool)$paste['burn_after_read'],
|
||||
'hasPassword' => (bool)$paste['has_password'],
|
||||
'created' => intval($paste['created_at'])
|
||||
));
|
||||
|
||||
} catch(PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(array('error' => 'Server error'));
|
||||
}
|
||||
function buildEncryptedPayloadResponse(array $data): array
|
||||
{
|
||||
$ivBinary = empty($data['iv']) ? '' : pack('C*', ...$data['iv']);
|
||||
$cipherBinary = empty($data['data']) ? '' : pack('C*', ...$data['data']);
|
||||
|
||||
return [
|
||||
'iv' => array_values($data['iv']),
|
||||
'data' => array_values($data['data']),
|
||||
'ivBase64' => base64urlEncode($ivBinary),
|
||||
'dataBase64' => base64urlEncode($cipherBinary),
|
||||
];
|
||||
}
|
||||
|
||||
function handleGet(PDO $pdo, string $id): void
|
||||
{
|
||||
if (!isValidPasteId($id)) {
|
||||
jsonResponse(['error' => 'Invalid ID', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM pastes WHERE id = ? AND expires_at > ?');
|
||||
$stmt->execute([$id, time()]);
|
||||
$paste = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$paste) {
|
||||
jsonResponse(['error' => 'Paste not found or expired', 'apiVersion' => API_VERSION], 404);
|
||||
}
|
||||
|
||||
$data = json_decode((string) $paste['data'], true);
|
||||
if (!is_array($data) || !isset($data['iv'], $data['data'])) {
|
||||
jsonResponse(['error' => 'Stored payload is invalid', 'apiVersion' => API_VERSION], 500);
|
||||
}
|
||||
|
||||
$responsePayload = buildEncryptedPayloadResponse($data);
|
||||
|
||||
if (!empty($paste['burn_after_read'])) {
|
||||
$deleteStmt = $pdo->prepare('DELETE FROM pastes WHERE id = ?');
|
||||
$deleteStmt->execute([$id]);
|
||||
} else {
|
||||
$updateStmt = $pdo->prepare('UPDATE pastes SET views = views + 1 WHERE id = ?');
|
||||
$updateStmt->execute([$id]);
|
||||
$paste['views'] = ((int) $paste['views']) + 1;
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'apiVersion' => API_VERSION,
|
||||
'id' => $id,
|
||||
'encryptedData' => $responsePayload,
|
||||
'data' => [
|
||||
'iv' => $responsePayload['iv'],
|
||||
'data' => $responsePayload['data'],
|
||||
],
|
||||
'burnAfterRead' => (bool) $paste['burn_after_read'],
|
||||
'hasPassword' => (bool) $paste['has_password'],
|
||||
'created' => (int) $paste['created_at'],
|
||||
'expiresAt' => (int) $paste['expires_at'],
|
||||
'views' => (int) $paste['views'],
|
||||
]);
|
||||
}
|
||||
|
||||
function handleMeta(PDO $pdo, string $id): void
|
||||
{
|
||||
if (!isValidPasteId($id)) {
|
||||
jsonResponse(['error' => 'Invalid ID', 'apiVersion' => API_VERSION], 400);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, created_at, expires_at, burn_after_read, has_password, views FROM pastes WHERE id = ? AND expires_at > ?');
|
||||
$stmt->execute([$id, time()]);
|
||||
$paste = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$paste) {
|
||||
jsonResponse(['error' => 'Paste not found or expired', 'apiVersion' => API_VERSION], 404);
|
||||
}
|
||||
|
||||
$baseUrl = buildBaseUrl();
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'apiVersion' => API_VERSION,
|
||||
'id' => $paste['id'],
|
||||
'shareUrl' => $baseUrl . '/p/' . rawurlencode((string) $paste['id']),
|
||||
'retrieveUrl' => $baseUrl . '/api/pastes/' . rawurlencode((string) $paste['id']),
|
||||
'created' => (int) $paste['created_at'],
|
||||
'expiresAt' => (int) $paste['expires_at'],
|
||||
'remainingSeconds' => max(0, (int) $paste['expires_at'] - time()),
|
||||
'burnAfterRead' => (bool) $paste['burn_after_read'],
|
||||
'hasPassword' => (bool) $paste['has_password'],
|
||||
'views' => (int) $paste['views'],
|
||||
]);
|
||||
}
|
||||
|
||||
function generateFallbackId(): string
|
||||
{
|
||||
$bytes = random_bytes(12);
|
||||
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
let pendingKey = null;
|
||||
let pendingData = null;
|
||||
|
||||
const FONT_STACK_SANS = "'Vazirmatn Local', 'Vazirmatn', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
|
||||
const FONT_STACK_MONO = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace";
|
||||
|
||||
const MAX_CUSTOM_EXPIRY_SECONDS = 31536000;
|
||||
const MIN_CUSTOM_EXPIRY_SECONDS = 300;
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('errorDisplay').style.display = 'flex';
|
||||
document.getElementById('errorMessage').textContent = msg;
|
||||
@@ -12,34 +18,229 @@ function clearError() {
|
||||
|
||||
function isRTL(text) {
|
||||
const rtlRegex = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
|
||||
return rtlRegex.test(text);
|
||||
return rtlRegex.test(text || '');
|
||||
}
|
||||
|
||||
function applyDirectionalStyles(element, text, rtlFont, ltrFont) {
|
||||
if (!element) return;
|
||||
|
||||
if (isRTL(text)) {
|
||||
element.style.direction = 'rtl';
|
||||
element.style.fontFamily = rtlFont;
|
||||
} else {
|
||||
element.style.direction = 'ltr';
|
||||
element.style.fontFamily = ltrFont;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTextareaDirection(e) {
|
||||
applyDirectionalStyles(e.target, e.target.value, FONT_STACK_SANS, FONT_STACK_MONO);
|
||||
}
|
||||
|
||||
const contentTextarea = document.getElementById('content');
|
||||
if (contentTextarea) {
|
||||
contentTextarea.addEventListener('input', function(e) {
|
||||
const text = e.target.value;
|
||||
if (isRTL(text)) {
|
||||
e.target.style.direction = 'rtl';
|
||||
e.target.style.fontFamily = "'Vazirmatn', sans-serif";
|
||||
} else {
|
||||
e.target.style.direction = 'ltr';
|
||||
e.target.style.fontFamily = "'JetBrains Mono', monospace";
|
||||
}
|
||||
contentTextarea.addEventListener('input', updateTextareaDirection);
|
||||
}
|
||||
|
||||
const subjectInput = document.getElementById('subject');
|
||||
if (subjectInput) {
|
||||
subjectInput.addEventListener('input', function(e) {
|
||||
applyDirectionalStyles(e.target, e.target.value, FONT_STACK_SANS, FONT_STACK_SANS);
|
||||
});
|
||||
}
|
||||
|
||||
const passwordInput = document.getElementById('passwordInput');
|
||||
if (passwordInput) {
|
||||
passwordInput.addEventListener('input', updatePasswordStrength);
|
||||
}
|
||||
|
||||
const expiresInSelect = document.getElementById('expiresIn');
|
||||
if (expiresInSelect) {
|
||||
expiresInSelect.addEventListener('change', toggleCustomExpiry);
|
||||
}
|
||||
|
||||
const editorToolbar = document.querySelector('.editor-toolbar');
|
||||
if (editorToolbar && contentTextarea) {
|
||||
editorToolbar.addEventListener('click', handleToolbarClick);
|
||||
}
|
||||
|
||||
|
||||
function handleToolbarClick(event) {
|
||||
const button = event.target.closest('[data-action]');
|
||||
if (!button || !contentTextarea) return;
|
||||
|
||||
const action = button.dataset.action;
|
||||
applyToolbarAction(action);
|
||||
}
|
||||
|
||||
function applyToolbarAction(action) {
|
||||
switch (action) {
|
||||
case 'bold':
|
||||
wrapSelection('**', '**', 'bold text');
|
||||
break;
|
||||
case 'italic':
|
||||
wrapSelection('*', '*', 'italic text');
|
||||
break;
|
||||
case 'strike':
|
||||
wrapSelection('~~', '~~', 'strikethrough');
|
||||
break;
|
||||
case 'heading':
|
||||
transformSelectedLines(function(line) {
|
||||
return line ? `# ${line.replace(/^#{1,6}\s+/, '')}` : '# Heading';
|
||||
}, { fallback: '# Heading' });
|
||||
break;
|
||||
case 'quote':
|
||||
transformSelectedLines(function(line) {
|
||||
return line ? `> ${line.replace(/^>\s?/, '')}` : '> Quote';
|
||||
}, { fallback: '> Quote' });
|
||||
break;
|
||||
case 'bullet':
|
||||
transformSelectedLines(function(line) {
|
||||
return line ? `- ${line.replace(/^[-*+]\s+/, '')}` : '- List item';
|
||||
}, { fallback: '- List item' });
|
||||
break;
|
||||
case 'numbered':
|
||||
transformSelectedLines(function(line, index) {
|
||||
return `${index + 1}. ${line.replace(/^\d+\.\s+/, '') || 'List item'}`;
|
||||
}, { fallback: '1. List item' });
|
||||
break;
|
||||
case 'link': {
|
||||
const selectedText = getSelectedText(contentTextarea) || 'link text';
|
||||
const url = window.prompt('Enter the URL for this link:', 'https://');
|
||||
if (url === null) {
|
||||
focusEditor();
|
||||
return;
|
||||
}
|
||||
const trimmedUrl = url.trim() || 'https://';
|
||||
replaceSelection(`[${selectedText}](${trimmedUrl})`, { selectInserted: false });
|
||||
break;
|
||||
}
|
||||
case 'code':
|
||||
wrapSelection('`', '`', 'code');
|
||||
break;
|
||||
case 'codeblock':
|
||||
wrapSelection('```\n', '\n```', 'your code here');
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
updateTextareaDirection({ target: contentTextarea });
|
||||
}
|
||||
|
||||
function getSelectedText(textarea) {
|
||||
return textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
}
|
||||
|
||||
function replaceSelection(replacement, options) {
|
||||
if (!contentTextarea) return;
|
||||
|
||||
const settings = Object.assign({ selectInserted: true, selectionStartOffset: 0, selectionEndOffset: 0 }, options || {});
|
||||
const start = contentTextarea.selectionStart;
|
||||
const end = contentTextarea.selectionEnd;
|
||||
const current = contentTextarea.value;
|
||||
|
||||
contentTextarea.value = current.slice(0, start) + replacement + current.slice(end);
|
||||
|
||||
if (settings.selectInserted) {
|
||||
contentTextarea.setSelectionRange(start + settings.selectionStartOffset, start + replacement.length - settings.selectionEndOffset);
|
||||
} else {
|
||||
const caret = start + replacement.length;
|
||||
contentTextarea.setSelectionRange(caret, caret);
|
||||
}
|
||||
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
function wrapSelection(prefix, suffix, placeholder) {
|
||||
if (!contentTextarea) return;
|
||||
|
||||
const selected = getSelectedText(contentTextarea);
|
||||
const inner = selected || placeholder;
|
||||
const replacement = `${prefix}${inner}${suffix}`;
|
||||
const selectionStartOffset = prefix.length;
|
||||
const selectionEndOffset = suffix.length;
|
||||
|
||||
replaceSelection(replacement, {
|
||||
selectInserted: true,
|
||||
selectionStartOffset,
|
||||
selectionEndOffset
|
||||
});
|
||||
}
|
||||
|
||||
function transformSelectedLines(transformer, options) {
|
||||
if (!contentTextarea) return;
|
||||
|
||||
const settings = Object.assign({ fallback: '' }, options || {});
|
||||
const value = contentTextarea.value;
|
||||
const start = contentTextarea.selectionStart;
|
||||
const end = contentTextarea.selectionEnd;
|
||||
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
|
||||
const nextNewlineIndex = value.indexOf('\n', end);
|
||||
const lineEnd = nextNewlineIndex === -1 ? value.length : nextNewlineIndex;
|
||||
const selectedBlock = value.slice(lineStart, lineEnd);
|
||||
const lines = selectedBlock ? selectedBlock.split('\n') : [settings.fallback];
|
||||
const transformed = lines.map(function(line, index) {
|
||||
return transformer(line, index);
|
||||
}).join('\n');
|
||||
|
||||
contentTextarea.value = value.slice(0, lineStart) + transformed + value.slice(lineEnd);
|
||||
contentTextarea.setSelectionRange(lineStart, lineStart + transformed.length);
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
function focusEditor() {
|
||||
if (!contentTextarea) return;
|
||||
contentTextarea.focus();
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
const enabled = document.getElementById('enablePassword').checked;
|
||||
const wrapper = document.getElementById('passwordWrapper');
|
||||
wrapper.classList.toggle('show', enabled);
|
||||
|
||||
if (!enabled) {
|
||||
document.getElementById('passwordInput').value = '';
|
||||
resetPasswordStrength();
|
||||
} else {
|
||||
updatePasswordStrength();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCustomExpiry() {
|
||||
const isCustom = document.getElementById('expiresIn').value === 'custom';
|
||||
const wrapper = document.getElementById('customExpiryWrapper');
|
||||
wrapper.classList.toggle('show', isCustom);
|
||||
|
||||
if (isCustom) {
|
||||
setCustomExpiryBounds();
|
||||
const input = document.getElementById('customExpiry');
|
||||
if (!input.value) {
|
||||
const defaultDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
input.value = toLocalDateTimeValue(defaultDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setCustomExpiryBounds() {
|
||||
const input = document.getElementById('customExpiry');
|
||||
if (!input) return;
|
||||
|
||||
input.min = toLocalDateTimeValue(new Date(Date.now() + MIN_CUSTOM_EXPIRY_SECONDS * 1000));
|
||||
input.max = toLocalDateTimeValue(new Date(Date.now() + MAX_CUSTOM_EXPIRY_SECONDS * 1000));
|
||||
}
|
||||
|
||||
function toLocalDateTimeValue(date) {
|
||||
const offset = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offset * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
const Base64 = {
|
||||
encode(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
for (let b of bytes) bin += String.fromCharCode(b);
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
},
|
||||
decode(str) {
|
||||
@@ -67,19 +268,294 @@ async function deriveKeyFromPassword(password, salt) {
|
||||
}
|
||||
|
||||
function genId() {
|
||||
const arr = new Uint8Array(16);
|
||||
const arr = new Uint8Array(12);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
||||
return Base64.encode(arr);
|
||||
}
|
||||
|
||||
function buildEncryptedPayload(subject, content) {
|
||||
return JSON.stringify({
|
||||
subject: subject || '',
|
||||
content: content
|
||||
});
|
||||
}
|
||||
|
||||
function parseDecryptedPayload(text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') {
|
||||
return {
|
||||
subject: typeof parsed.subject === 'string' ? parsed.subject : '',
|
||||
content: parsed.content
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Backward compatibility with old plain-text payloads.
|
||||
}
|
||||
|
||||
return {
|
||||
subject: '',
|
||||
content: text
|
||||
};
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
document.getElementById('content').value = '';
|
||||
document.getElementById('subject').value = '';
|
||||
document.getElementById('passwordInput').value = '';
|
||||
document.getElementById('enablePassword').checked = false;
|
||||
document.getElementById('burnAfterRead').checked = false;
|
||||
document.getElementById('expiresIn').value = '86400';
|
||||
document.getElementById('customExpiry').value = '';
|
||||
document.getElementById('customExpiryWrapper').classList.remove('show');
|
||||
document.getElementById('passwordWrapper').classList.remove('show');
|
||||
resetPasswordStrength();
|
||||
applyDirectionalStyles(document.getElementById('content'), '', FONT_STACK_SANS, FONT_STACK_MONO);
|
||||
applyDirectionalStyles(document.getElementById('subject'), '', FONT_STACK_SANS, FONT_STACK_SANS);
|
||||
setCustomExpiryBounds();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return (text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeAttribute(text) {
|
||||
return escapeHtml(text).replace(/`/g, '`');
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
const trimmed = (url || '').trim();
|
||||
if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text) {
|
||||
if (!text) return '';
|
||||
|
||||
const placeholders = [];
|
||||
let work = text.replace(/\r\n/g, '\n');
|
||||
|
||||
work = work.replace(/`([^`\n]+)`/g, function(_, code) {
|
||||
const token = `@@MDTOKEN${placeholders.length}@@`;
|
||||
placeholders.push({ token, html: `<code>${escapeHtml(code)}</code>` });
|
||||
return token;
|
||||
});
|
||||
|
||||
work = work.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, function(_, label, url, title) {
|
||||
const safeUrl = sanitizeUrl(url);
|
||||
if (!safeUrl) {
|
||||
return label;
|
||||
}
|
||||
const attrs = [`href="${escapeAttribute(safeUrl)}"`, 'target="_blank"', 'rel="noopener noreferrer"'];
|
||||
if (title) {
|
||||
attrs.push(`title="${escapeAttribute(title)}"`);
|
||||
}
|
||||
const token = `@@MDTOKEN${placeholders.length}@@`;
|
||||
placeholders.push({ token, html: `<a ${attrs.join(' ')}>${escapeHtml(label)}</a>` });
|
||||
return token;
|
||||
});
|
||||
|
||||
let html = escapeHtml(work);
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
|
||||
html = html.replace(/(^|[^_])_([^_\n]+)_(?!_)/g, '$1<em>$2</em>');
|
||||
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||||
|
||||
placeholders.forEach(function(entry) {
|
||||
html = html.replace(entry.token, entry.html);
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderMarkdown(content) {
|
||||
const normalized = (content || '').replace(/\r\n/g, '\n');
|
||||
const blocks = [];
|
||||
const lines = normalized.split('\n');
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (!lines[i].trim()) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lines[i].startsWith('```')) {
|
||||
const lang = lines[i].slice(3).trim();
|
||||
i += 1;
|
||||
const codeLines = [];
|
||||
while (i < lines.length && !lines[i].startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i < lines.length && lines[i].startsWith('```')) {
|
||||
i += 1;
|
||||
}
|
||||
const langAttr = lang ? ` data-lang="${escapeAttribute(lang)}"` : '';
|
||||
blocks.push(`<pre class="markdown-code"><code${langAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^#{1,6}\s+/.test(lines[i])) {
|
||||
const match = lines[i].match(/^(#{1,6})\s+(.*)$/);
|
||||
const level = match[1].length;
|
||||
blocks.push(`<h${level}>${renderInlineMarkdown(match[2].trim())}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(lines[i])) {
|
||||
const quoteLines = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
||||
quoteLines.push(lines[i].replace(/^>\s?/, ''));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push(`<blockquote>${quoteLines.map(line => renderInlineMarkdown(line)).join('<br>')}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*+]\s+/.test(lines[i])) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^[-*+]\s+/.test(lines[i])) {
|
||||
items.push(`<li>${renderInlineMarkdown(lines[i].replace(/^[-*+]\s+/, ''))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
blocks.push(`<ul>${items.join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(lines[i])) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
||||
items.push(`<li>${renderInlineMarkdown(lines[i].replace(/^\d+\.\s+/, ''))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
blocks.push(`<ol>${items.join('')}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() &&
|
||||
!/^#{1,6}\s+/.test(lines[i]) &&
|
||||
!/^>\s?/.test(lines[i]) &&
|
||||
!/^[-*+]\s+/.test(lines[i]) &&
|
||||
!/^\d+\.\s+/.test(lines[i]) &&
|
||||
!lines[i].startsWith('```')
|
||||
) {
|
||||
paragraphLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
blocks.push(`<p>${paragraphLines.map(line => renderInlineMarkdown(line)).join('<br>')}</p>`);
|
||||
}
|
||||
|
||||
return blocks.join('');
|
||||
}
|
||||
|
||||
function renderContent(content) {
|
||||
const contentBox = document.getElementById('decryptedContent');
|
||||
contentBox.dataset.rawContent = content || '';
|
||||
contentBox.innerHTML = renderMarkdown(content || '');
|
||||
}
|
||||
|
||||
function showDecryptedContent(payload, burnAfterRead) {
|
||||
const subjectWrapper = document.getElementById('decryptedSubjectWrapper');
|
||||
const subjectBox = document.getElementById('decryptedSubject');
|
||||
const contentBox = document.getElementById('decryptedContent');
|
||||
|
||||
document.getElementById('passwordPrompt').classList.remove('show');
|
||||
document.getElementById('decryptView').classList.add('show');
|
||||
document.getElementById('createView').style.display = 'none';
|
||||
|
||||
if (burnAfterRead) {
|
||||
document.getElementById('burnNotice').style.display = 'flex';
|
||||
} else {
|
||||
document.getElementById('burnNotice').style.display = 'none';
|
||||
}
|
||||
|
||||
if (payload.subject && payload.subject.trim()) {
|
||||
subjectWrapper.style.display = 'block';
|
||||
subjectBox.textContent = payload.subject;
|
||||
applyDirectionalStyles(subjectBox, payload.subject, FONT_STACK_SANS, FONT_STACK_SANS);
|
||||
document.title = `${payload.subject} - Secure Pastebin`;
|
||||
} else {
|
||||
subjectWrapper.style.display = 'none';
|
||||
subjectBox.textContent = '';
|
||||
document.title = 'Secure Pastebin - End-to-End Encrypted Message Sharing';
|
||||
}
|
||||
|
||||
renderContent(payload.content);
|
||||
applyDirectionalStyles(contentBox, payload.content, FONT_STACK_SANS, FONT_STACK_MONO);
|
||||
}
|
||||
|
||||
function getExpiryPayload() {
|
||||
const selected = document.getElementById('expiresIn').value;
|
||||
|
||||
if (selected !== 'custom') {
|
||||
const expiresIn = parseInt(selected, 10);
|
||||
return {
|
||||
expiresIn,
|
||||
expiresAt: null,
|
||||
displayDate: new Date(Date.now() + expiresIn * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
const customValue = document.getElementById('customExpiry').value;
|
||||
if (!customValue) {
|
||||
throw new Error('Please choose a custom expiration date and time');
|
||||
}
|
||||
|
||||
const customDate = new Date(customValue);
|
||||
if (Number.isNaN(customDate.getTime())) {
|
||||
throw new Error('Custom expiration date is invalid');
|
||||
}
|
||||
|
||||
const expiresAt = Math.floor(customDate.getTime() / 1000);
|
||||
const deltaSeconds = expiresAt - Math.floor(Date.now() / 1000);
|
||||
|
||||
if (deltaSeconds < MIN_CUSTOM_EXPIRY_SECONDS) {
|
||||
throw new Error('Custom expiration must be at least 5 minutes from now');
|
||||
}
|
||||
|
||||
if (deltaSeconds > MAX_CUSTOM_EXPIRY_SECONDS) {
|
||||
throw new Error('Custom expiration cannot be more than 365 days from now');
|
||||
}
|
||||
|
||||
return {
|
||||
expiresIn: deltaSeconds,
|
||||
expiresAt,
|
||||
displayDate: customDate
|
||||
};
|
||||
}
|
||||
|
||||
function buildFullShareUrl(id, keyData, hasPassword) {
|
||||
const suffix = hasPassword ? `${keyData}:pwd` : keyData;
|
||||
return `${location.origin}/p/${encodeURIComponent(id)}#${suffix}`;
|
||||
}
|
||||
|
||||
function buildShortShareUrl(id, keyData, hasPassword) {
|
||||
const suffix = hasPassword ? `${keyData}:pwd` : keyData;
|
||||
return `${location.origin}/#${encodeURIComponent(id)}:${suffix}`;
|
||||
}
|
||||
|
||||
async function createPaste() {
|
||||
clearError();
|
||||
const subject = document.getElementById('subject').value.trim();
|
||||
const content = document.getElementById('content').value.trim();
|
||||
if (!content) return showError('Please enter content to encrypt');
|
||||
|
||||
const hasPassword = document.getElementById('enablePassword').checked;
|
||||
const password = document.getElementById('passwordInput').value;
|
||||
|
||||
|
||||
if (hasPassword && password.length < 4) {
|
||||
return showError('Password must be at least 4 characters');
|
||||
}
|
||||
@@ -90,8 +566,10 @@ async function createPaste() {
|
||||
btnText.innerHTML = '<span class="loading"></span> Encrypting...';
|
||||
|
||||
try {
|
||||
let key, keyToExport;
|
||||
|
||||
const expiry = getExpiryPayload();
|
||||
let key;
|
||||
let keyToExport;
|
||||
|
||||
if (hasPassword) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
key = await deriveKeyFromPassword(password, salt);
|
||||
@@ -100,48 +578,47 @@ async function createPaste() {
|
||||
key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
keyToExport = await crypto.subtle.exportKey('raw', key);
|
||||
}
|
||||
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
new TextEncoder().encode(content)
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
new TextEncoder().encode(buildEncryptedPayload(subject, content))
|
||||
);
|
||||
|
||||
|
||||
const id = genId();
|
||||
const payload = {
|
||||
iv: Array.from(iv),
|
||||
data: Array.from(new Uint8Array(encrypted))
|
||||
const payload = {
|
||||
iv: Array.from(iv),
|
||||
data: Array.from(new Uint8Array(encrypted))
|
||||
};
|
||||
|
||||
const res = await fetch('/api/create', {
|
||||
|
||||
const res = await fetch('/api/pastes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
encryptedData: payload,
|
||||
expiresIn: parseInt(document.getElementById('expiresIn').value),
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
encryptedData: payload,
|
||||
expiresIn: expiry.expiresIn,
|
||||
customExpiresAt: expiry.expiresAt,
|
||||
burnAfterRead: document.getElementById('burnAfterRead').checked,
|
||||
hasPassword: hasPassword
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || 'Server error');
|
||||
|
||||
let url;
|
||||
if (hasPassword) {
|
||||
url = `${location.origin}/#${id}:${Base64.encode(keyToExport)}:pwd`;
|
||||
} else {
|
||||
url = `${location.origin}/#${id}:${Base64.encode(keyToExport)}`;
|
||||
}
|
||||
|
||||
document.getElementById('shareUrl').value = url;
|
||||
document.getElementById('expiryTime').textContent = new Date(Date.now() + parseInt(document.getElementById('expiresIn').value) * 1000).toLocaleString();
|
||||
const keyFragment = Base64.encode(keyToExport);
|
||||
const fullShareUrl = buildFullShareUrl(id, keyFragment, hasPassword);
|
||||
const shortShareUrl = buildShortShareUrl(id, keyFragment, hasPassword);
|
||||
document.getElementById('shareUrl').value = fullShareUrl;
|
||||
document.getElementById('shareUrl').dataset.shortUrl = shortShareUrl;
|
||||
document.getElementById('expiryTime').textContent = expiry.displayDate.toLocaleString();
|
||||
document.getElementById('passwordNotice').style.display = hasPassword ? 'flex' : 'none';
|
||||
document.getElementById('resultBox').classList.add('show');
|
||||
document.getElementById('content').value = '';
|
||||
|
||||
document.getElementById('resultBox').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
resetCreateForm();
|
||||
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
@@ -150,49 +627,76 @@ async function createPaste() {
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptPaste() {
|
||||
function parseLocationForPaste() {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash.includes(':')) return;
|
||||
|
||||
const parts = hash.split(':');
|
||||
const id = parts[0];
|
||||
const keyData = parts[1];
|
||||
const isPasswordProtected = parts[2] === 'pwd';
|
||||
|
||||
const pathMatch = location.pathname.match(/^\/p\/([^/]+)\/?$/);
|
||||
|
||||
if (pathMatch && hash) {
|
||||
const id = decodeURIComponent(pathMatch[1]);
|
||||
const isPasswordProtected = hash.endsWith(':pwd');
|
||||
const keyData = isPasswordProtected ? hash.slice(0, -4) : hash;
|
||||
|
||||
if (id && keyData) {
|
||||
return { id, keyData, isPasswordProtected };
|
||||
}
|
||||
}
|
||||
|
||||
if (hash.includes(':')) {
|
||||
const parts = hash.split(':');
|
||||
const id = parts[0];
|
||||
const keyData = parts[1];
|
||||
const isPasswordProtected = parts[2] === 'pwd';
|
||||
|
||||
if (id && keyData) {
|
||||
return { id, keyData, isPasswordProtected };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function decryptPaste() {
|
||||
const pasteRef = parseLocationForPaste();
|
||||
if (!pasteRef) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/get/${id}`);
|
||||
const res = await fetch(`/api/get/${encodeURIComponent(pasteRef.id)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
if (data.hasPassword || isPasswordProtected) {
|
||||
pendingKey = keyData;
|
||||
if (data.hasPassword || pasteRef.isPasswordProtected) {
|
||||
pendingKey = pasteRef.keyData;
|
||||
pendingData = data;
|
||||
document.getElementById('createView').style.display = 'none';
|
||||
document.getElementById('passwordPrompt').classList.add('show');
|
||||
document.getElementById('passwordPrompt').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
|
||||
await performDecryption(keyData, data);
|
||||
|
||||
|
||||
await performDecryption(pasteRef.keyData, data);
|
||||
|
||||
} catch (err) {
|
||||
showError('Failed: ' + err.message);
|
||||
setTimeout(() => location.href = '/', 3000);
|
||||
setTimeout(() => {
|
||||
location.href = '/';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptWithPassword() {
|
||||
const password = document.getElementById('decryptPassword').value;
|
||||
if (!password) return;
|
||||
|
||||
|
||||
const btn = document.querySelector('#passwordPrompt .btn');
|
||||
const btnText = document.getElementById('decryptBtnText');
|
||||
btn.disabled = true;
|
||||
btnText.innerHTML = '<span class="loading"></span> Decrypting...';
|
||||
|
||||
|
||||
try {
|
||||
const salt = Base64.decode(pendingKey);
|
||||
const key = await deriveKeyFromPassword(password, salt);
|
||||
await performDecryption(key, pendingData, true);
|
||||
document.getElementById('passwordError').style.display = 'none';
|
||||
} catch (err) {
|
||||
document.getElementById('passwordError').style.display = 'block';
|
||||
btn.disabled = false;
|
||||
@@ -202,7 +706,7 @@ async function decryptWithPassword() {
|
||||
|
||||
async function performDecryption(keyOrData, data, isKeyObject = false) {
|
||||
let key;
|
||||
|
||||
|
||||
if (isKeyObject) {
|
||||
key = keyOrData;
|
||||
} else {
|
||||
@@ -211,45 +715,159 @@ async function performDecryption(keyOrData, data, isKeyObject = false) {
|
||||
'raw', keyRaw, { name: 'AES-GCM', length: 256 }, false, ['decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: new Uint8Array(data.data.iv) },
|
||||
key,
|
||||
{ name: 'AES-GCM', iv: new Uint8Array(data.data.iv) },
|
||||
key,
|
||||
new Uint8Array(data.data.data)
|
||||
);
|
||||
|
||||
const text = new TextDecoder().decode(decrypted);
|
||||
|
||||
document.getElementById('passwordPrompt').classList.remove('show');
|
||||
document.getElementById('decryptView').classList.add('show');
|
||||
|
||||
if (data.burnAfterRead) {
|
||||
document.getElementById('burnNotice').style.display = 'flex';
|
||||
}
|
||||
|
||||
const contentBox = document.getElementById('decryptedContent');
|
||||
contentBox.textContent = text;
|
||||
|
||||
if (isRTL(text)) {
|
||||
contentBox.style.direction = 'rtl';
|
||||
contentBox.style.fontFamily = "'Vazirmatn', sans-serif";
|
||||
} else {
|
||||
contentBox.style.direction = 'ltr';
|
||||
contentBox.style.fontFamily = "'JetBrains Mono', monospace";
|
||||
}
|
||||
|
||||
history.replaceState(null, null, ' ');
|
||||
|
||||
const decoded = new TextDecoder().decode(decrypted);
|
||||
const payload = parseDecryptedPayload(decoded);
|
||||
showDecryptedContent(payload, data.burnAfterRead);
|
||||
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
const inp = document.getElementById('shareUrl');
|
||||
inp.select();
|
||||
async function copyTextToClipboard(text) {
|
||||
if (!text) return;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const temp = document.createElement('textarea');
|
||||
temp.value = text;
|
||||
document.body.appendChild(temp);
|
||||
temp.select();
|
||||
document.execCommand('copy');
|
||||
const btn = document.querySelector('.btn-copy');
|
||||
btn.textContent = '✅ Copied!';
|
||||
setTimeout(() => btn.textContent = '📋 Copy', 2000);
|
||||
document.body.removeChild(temp);
|
||||
}
|
||||
|
||||
async function copyUrl() {
|
||||
const btn = document.getElementById('copyUrlBtn');
|
||||
|
||||
try {
|
||||
await copyTextToClipboard(document.getElementById('shareUrl').value);
|
||||
btn.textContent = '✅ Copied!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = '📋 Copy';
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
showError('Unable to copy the full share URL');
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShortUrl() {
|
||||
const btn = document.getElementById('copyShortUrlBtn');
|
||||
const shareUrl = document.getElementById('shareUrl');
|
||||
const shortUrl = shareUrl ? shareUrl.dataset.shortUrl : '';
|
||||
|
||||
if (!shortUrl) {
|
||||
showError('Short link is not available yet');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyTextToClipboard(shortUrl);
|
||||
btn.textContent = '✅ Copied!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = '⚡ Copy short';
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
showError('Unable to copy the short share URL');
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDecryptedContent() {
|
||||
const btn = document.getElementById('copyDecryptedBtn');
|
||||
const subject = document.getElementById('decryptedSubject').textContent.trim();
|
||||
const content = document.getElementById('decryptedContent').dataset.rawContent || '';
|
||||
const textToCopy = subject ? `Subject: ${subject}\n\n${content}` : content;
|
||||
|
||||
try {
|
||||
await copyTextToClipboard(textToCopy);
|
||||
btn.textContent = '✅ Copied!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = '📋 Copy Text';
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
showError('Unable to copy decrypted content');
|
||||
}
|
||||
}
|
||||
|
||||
function resetPasswordStrength() {
|
||||
const meterFill = document.getElementById('passwordStrengthFill');
|
||||
const meterLabel = document.getElementById('passwordStrengthLabel');
|
||||
const wrapper = document.getElementById('passwordStrength');
|
||||
if (!meterFill || !meterLabel || !wrapper) return;
|
||||
|
||||
wrapper.dataset.strength = 'empty';
|
||||
meterFill.style.width = '0%';
|
||||
meterLabel.textContent = 'Password strength will appear here';
|
||||
}
|
||||
|
||||
function calculatePasswordStrength(password) {
|
||||
let score = 0;
|
||||
|
||||
if (password.length >= 8) score += 1;
|
||||
if (password.length >= 12) score += 1;
|
||||
if (password.length >= 16) score += 1;
|
||||
if (/[a-z]/.test(password)) score += 1;
|
||||
if (/[A-Z]/.test(password)) score += 1;
|
||||
if (/\d/.test(password)) score += 1;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score += 1;
|
||||
|
||||
if (password.length > 0 && password.length < 6) {
|
||||
score = Math.min(score, 1);
|
||||
}
|
||||
|
||||
if (score <= 1) return { label: 'Weak', width: 25, level: 'weak' };
|
||||
if (score <= 3) return { label: 'Fair', width: 50, level: 'fair' };
|
||||
if (score <= 5) return { label: 'Good', width: 75, level: 'good' };
|
||||
return { label: 'Strong', width: 100, level: 'strong' };
|
||||
}
|
||||
|
||||
function updatePasswordStrength() {
|
||||
const wrapper = document.getElementById('passwordStrength');
|
||||
const input = document.getElementById('passwordInput');
|
||||
const meterFill = document.getElementById('passwordStrengthFill');
|
||||
const meterLabel = document.getElementById('passwordStrengthLabel');
|
||||
if (!wrapper || !input || !meterFill || !meterLabel) return;
|
||||
|
||||
const password = input.value;
|
||||
if (!password) {
|
||||
resetPasswordStrength();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = calculatePasswordStrength(password);
|
||||
wrapper.dataset.strength = result.level;
|
||||
meterFill.style.width = `${result.width}%`;
|
||||
meterLabel.textContent = `${result.label} password`;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (!contentTextarea) return;
|
||||
if (document.activeElement !== contentTextarea) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
|
||||
if (key === 'b') {
|
||||
event.preventDefault();
|
||||
applyToolbarAction('bold');
|
||||
} else if (key === 'i') {
|
||||
event.preventDefault();
|
||||
applyToolbarAction('italic');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
if (location.hash.length > 1) decryptPaste();
|
||||
setCustomExpiryBounds();
|
||||
resetPasswordStrength();
|
||||
if (location.hash.length > 0 || /^\/p\//.test(location.pathname)) {
|
||||
decryptPaste();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user