Add files via upload

This commit is contained in:
M.M.Azizi
2026-04-23 18:45:24 +03:30
committed by GitHub
parent 7c8e5ae45f
commit bb535a2f31
6 changed files with 3210 additions and 1160 deletions
+453 -346
View File
@@ -2,451 +2,551 @@
<img src="https://sphost.theazizi.ir/favicon.svg" width="100" height="100" alt="Secure Pastebin Logo"> <img src="https://sphost.theazizi.ir/favicon.svg" width="100" height="100" alt="Secure Pastebin Logo">
</p> </p>
# 🔐 Secure Pastebin (Self-Hosted Ver) # 🔐 Secure Pastebin (Self-Hosted)
> **Self-Hosted, Zero-Knowledge, End-to-End Encrypted Pastebin** > **Self-hosted, zero-knowledge, end-to-end encrypted pastebin built with PHP, MySQL, Web Crypto API, and a responsive single-page UI.**
>
> Share sensitive messages securely. Server cannot read your data. Ever. 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.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PHP](https://img.shields.io/badge/PHP-7.4%2B-blue.svg)](https://php.net) [![PHP](https://img.shields.io/badge/PHP-7.4%2B-blue.svg)](https://php.net)
[![Crypto](https://img.shields.io/badge/Encryption-AES--256--GCM-green.svg)](https://en.wikipedia.org/wiki/Galois/Counter_Mode) [![Crypto](https://img.shields.io/badge/Encryption-AES--256--GCM-green.svg)](https://en.wikipedia.org/wiki/Galois/Counter_Mode)
[![Deployment](https://img.shields.io/badge/Deployment-Self--Hosted-orange.svg)](#) [![Deployment](https://img.shields.io/badge/Deployment-Self--Hosted-orange.svg)](#installation)
[![Security](https://img.shields.io/badge/Security-Zero--Knowledge-success.svg)](#) [![API](https://img.shields.io/badge/API-Documented-success.svg)](#api)
🌐 **Live Demo:** https://sphost.theazizi.ir 🌐 **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 ## ✨ Features
| Feature | Description | Security Impact | - 🔒 **Client-side AES-256-GCM encryption** using the native Web Crypto API
|---------|-------------|---------------| - 🛡️ **Zero-knowledge architecture** — server never receives plaintext, password, or key
| 🔒 **E2E Encryption** | AES-256-GCM in browser before transmission | Server sees only ciphertext | - 🧾 **Optional subject field** included inside the encrypted payload
| 🛡️ **Zero-Knowledge** | Server has zero access to keys or plaintext | Mathematically provable | - 🔑 **Optional password protection** with PBKDF2 (100,000 iterations, SHA-256)
| 🔑 **Password Protection** | PBKDF2 with 100,000 iterations | Brute-force resistant | - 🔥 **Burn after reading** support
| 🔥 **Burn After Read** | Auto-delete after first access | Forward secrecy | - ⏱️ **Preset expiration** plus **custom expiration date & time**
| ⏱️ **Auto-Expiration** | 1 hour to 30 days configurable | Limits exposure window | - 📝 **Markdown authoring + rendering**
| 🌐 **RTL Support** | Persian, Arabic, Hebrew typography | Accessibility | - compact formatting toolbar in the composer
| 📱 **Responsive** | Mobile-first design | Usability | - 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.
``` ### Important note
┌────────────────────────────────────────────────────────────────┐
│ ZERO-KNOWLEDGE GUARANTEE │ If the full share URL is lost, the message is **not recoverable**. The server cannot reconstruct the decryption key.
├────────────────────────────────────────────────────────────────┤
│ │ ---
│ USER BROWSER SERVER / DATABASE │
│ ───────────── ───────────────── │ ## 🧱 Current stack
│ │
│ ┌─────────────┐ ┌─────────────────┐ │ - **Frontend:** HTML, CSS, vanilla JavaScript
│ │ Generate │ ──NOT SENT──────► │ │ │ - **Crypto:** Web Crypto API
│ │ AES-256 Key │ │ NO KEYS STORED │ │ - **Backend/router:** PHP
│ └─────────────┘ │ │ │ - **Database:** MySQL / MariaDB
│ └─────────────────┘ │ - **Storage model:** encrypted payload + metadata only
│ ┌─────────────┐ │
│ │ Encrypt │ ──NOT SENT──────────────────────────────────►│ ---
│ │ Plaintext │ │
│ │ with Key │ │ ## 🆕 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. fileciteturn0file0
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Send: │ ──HTTPS─────────► │ Store: │ │ ---
│ │ • ID │ │ • ID │ │
│ │ • IV │ │ • IV │ │ ## 🧩 Share link formats
│ │ • Ciphertext│ │ • Ciphertext │ │
│ │ • Metadata │ │ • Metadata │ │ ### Full share link
│ └─────────────┘ │ │ │
│ │ NO PLAINTEXT │ │ ```text
│ ┌─────────────┐ │ NO PASSWORD │ │ https://your-domain.com/p/AbCdEf1234567890#BASE64URL_KEY
│ │ Key Stored │ │ NO KEY │ │
│ │ in URL: │ │ │ │
│ │ │ └─────────────────┘ │
│ │ #id:key ◄─┘ NEVER in HTTP headers │
│ │ │ │
│ └─────────────┘ Fragment not sent to server │
│ │
└────────────────────────────────────────────────────────────────┘
``` ```
### Technical Specifications ### Full share link with password flag
| Component | Standard | Parameters | ```text
|-----------|----------|------------| https://your-domain.com/p/AbCdEf1234567890#BASE64URL_SALT:pwd
| 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
})
});
``` ```
### 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 ## 🚀 Installation
### System Requirements ## 1) Database
| Requirement | Minimum | Recommended | Create a database, then import `database.sql`.
|-------------|---------|-------------|
| 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
```sql ```sql
CREATE DATABASE IF NOT EXISTS secure_pastebin CREATE TABLE IF NOT EXISTS pastes (
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE secure_pastebin;
CREATE TABLE pastes (
id VARCHAR(32) PRIMARY KEY, id VARCHAR(32) PRIMARY KEY,
data TEXT NOT NULL, data TEXT NOT NULL,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL, expires_at BIGINT NOT NULL,
burn_after_read TINYINT(1) DEFAULT 0, burn_after_read TINYINT(1) DEFAULT 0,
has_password TINYINT(1) DEFAULT 0, has_password TINYINT(1) DEFAULT 0,
views INT DEFAULT 0, views INT DEFAULT 0
INDEX idx_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
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 ```php
define('DB_HOST', 'localhost'); const DB_HOST = 'localhost';
define('DB_USER', 'your_cpanel_username_dbuser'); const DB_USER = 'your_db_user';
define('DB_PASS', 'your_secure_random_password'); const DB_PASS = 'your_db_password';
define('DB_NAME', 'your_cpanel_username_pastebin'); const DB_NAME = 'your_db_name';
``` ```
### Step 3: File Upload ## 3) Upload files
``` Upload the project files to your web root.
```text
/public_html/ /public_html/
├── index.php # API backend + router ├── .htaccess
├── index.html # Single-page application ├── api-docs.php
├── style.css # Complete styling (RTL included) ├── database.sql
├── script.js # Web Crypto implementation ├── index.html
├── .htaccess # URL rewriting rules ├── index.php
── database.sql # Schema (already imported) ── LICENSE
├── README.md
├── script.js
└── style.css
``` ```
### Step 4: SSL Enforcement ## 4) Enable URL rewriting
**cPanel Method:** Apache `.htaccess` used by the project:
1. SSL/TLS Status → Run AutoSSL
2. Force HTTPS Redirect: ON
**Cloudflare Method:** ```apache
1. DNS proxied through Cloudflare RewriteEngine On
2. SSL/TLS mode: Full (strict) RewriteBase /
3. Always Use HTTPS: ON
### Step 5: Verification Checklist RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
- [ ] Database connection successful (check error logs) RewriteRule ^ index.php [L]
- [ ] `POST /api/create` returns 201 with valid JSON ```
- [ ] `GET /api/get/{id}` returns encrypted data
- [ ] Auto-cleanup: Expired rows delete automatically ## 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:** ### Secure message composer
```
سلام، این پیام محرمانه من است. - optional subject
Hello, this is my secret message. - 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 ```json
{ {
"id": "7a3f9e2b8c1d4e5f6a7b8c9d0e1f2a3b", "encryptedData": {
"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]}", "iv": [12, 34, 56],
"created_at": 1708368000000, "data": [99, 88, 77]
"expires_at": 1708454400, }
"burn_after_read": 0, }
"has_password": 1, ```
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 "views": 0
} }
``` ```
**URL Generated (CONTAINS KEY):** ---
```
https://yoursite.com/#7a3f9e2b8c1d4e5f6a7b8c9d0e1f2a3b:c2FsdHNhbHRzYWx0:pwd ## 🧪 Health and options
↑ ↑
Base64URL salt Password flag ### Health
(for PBKDF2)
```bash
curl https://your-domain.com/api/health
``` ```
**Critical Security Note:** ### Options
- The URL fragment (`#...`) is **never sent** in HTTP headers
- Server logs contain: `GET /api/get/7a3f9e2b8c1d4e5f6a7b8c9d0e1f2a3b` ```bash
- Server logs **never contain**: The key, plaintext, or password 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. A secure context is required for the Web Crypto API.
### 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 |
--- ---
## 🛡️ Security Analysis ## 🛡️ Security notes
### Threat Model Matrix ### Protected well
| Attacker Capability | Data Access | Mitigation Status | - database leaks still expose ciphertext only
|---------------------|-------------|-------------------| - server admins do not have plaintext or keys
| **Passive network observer** | Encrypted TLS traffic only | ✅ Mitigated (TLS 1.3) | - password material stays client-side
| **Database breach** | Ciphertext, metadata only | ✅ Mitigated (no keys stored) | - URL fragment is not sent to the backend
| **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** |
### What We Protect Against ### Not protected well
1. **Server-side attacks**: Even with full server compromise, attacker gains zero cryptographic material - malware on sender or recipient device
2. **Legal requests**: No plaintext or keys to surrender (technical impossibility) - leaked full share URLs
3. **Insider threats**: System administrators cannot access user content - weak passwords chosen by users
4. **Database leaks**: Ciphertext without keys is information-theoretically secure - copied plaintext after decryption
- screenshots or shoulder surfing
### What We Cannot Protect Against ### Recommended operational practices
1. **Endpoint compromise**: Malware on sender/recipient device - send password separately from the URL
2. **Social engineering**: Users tricked into sharing URLs - use burn-after-read for highly sensitive messages
3. **Shoulder surfing**: Visual observation of decrypted content - prefer long random passwords
4. **Forensic analysis**: RAM dumps containing decrypted plaintext - use private browsing on shared devices
- do not paste highly sensitive links into third-party chatbots or analytics tools
### 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 |
--- ---
## 🌐 Browser Compatibility ## 🎨 UX and design notes
| Browser | Version | Web Crypto API | Status | - improved composer layout for desktop and mobile
|---------|---------|----------------|--------| - cleaner stacked settings cards for expiration and security options
| Chrome | 37+ | ✅ Full | Recommended | - compact Markdown toolbar
| Firefox | 34+ | ✅ Full | Recommended | - icon-only social links in the footer
| Safari | 7+ | ✅ Full | Supported | - consistent visual style shared by the app and API docs page
| 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.
--- ---
## ⚖️ Deployment Comparison ## 📌 Roadmap ideas
### Self-Hosted (This Repository) vs Cloudflare Worker - encrypted file attachments
- QR code for secure links
| Dimension | Self-Hosted | Cloudflare Worker | - separate key-sharing mode
|-----------|-------------|-------------------| - OpenAPI / Swagger export
| **Infrastructure** | Your server/cPanel | Cloudflare Edge Network | - admin-only cleanup / moderation tools
| **Data Sovereignty** | Full control | Third-party processing | - theme switcher
| **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
--- ---
## 🤝 Contributing ## 🤝 Contributing
Contributions welcome! Areas to improve: Issues and pull requests are welcome.
- [ ] File attachments (encrypted) If you open a PR, try to keep these guarantees intact:
- [ ] QR code generation for sharing
- [ ] Custom themes - client-side encryption only
- [ ] Browser extension - 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) MIT — 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) |
--- ---
@@ -455,4 +555,11 @@ Contributions welcome! Areas to improve:
**TheGreatAzizi** **TheGreatAzizi**
- GitHub: [@TheGreatAzizi](https://github.com/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
View File
@@ -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
View File
@@ -6,9 +6,6 @@
<title>Secure Pastebin - End-to-End Encrypted Message Sharing</title> <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."> <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="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"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
@@ -33,77 +30,6 @@
</div> </div>
</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 id="passwordPrompt" class="card password-prompt">
<div class="alert alert-info" style="margin-bottom: 20px;"> <div class="alert alert-info" style="margin-bottom: 20px;">
<span>🔐</span> <span>🔐</span>
@@ -122,17 +48,154 @@
<div id="decryptView" class="card decrypt-view"> <div id="decryptView" class="card decrypt-view">
<div id="burnNotice" class="burn-warning" style="display: none;"> <div id="burnNotice" class="burn-warning" style="display: none;">
<span>🔥</span> <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> </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> <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 📝 Create New Message
</button> </button>
</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>
<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">&lt;/&gt;</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 &amp; 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 &amp; 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"> <div class="card how-it-works">
<h2>🔍 How It Works</h2> <h2>🔍 How It Works</h2>
<div class="steps"> <div class="steps">
@@ -168,11 +231,11 @@
<div class="step-number">5</div> <div class="step-number">5</div>
<div class="step-content"> <div class="step-content">
<h3>Self-Destruction</h3> <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>
</div> </div>
<div class="tech-specs"> <div class="tech-specs">
<h3>🛡️ Technical Specifications</h3> <h3>🛡️ Technical Specifications</h3>
<div class="specs-grid"> <div class="specs-grid">
@@ -183,48 +246,27 @@
<div class="spec-item">No Server-Side Logs</div> <div class="spec-item">No Server-Side Logs</div>
<div class="spec-item">MySQL Database Storage</div> <div class="spec-item">MySQL Database Storage</div>
<div class="spec-item">Web Crypto API (Native)</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> </div>
</div> </div>
<footer> <footer>
<p>🛡️ Zero-Knowledge Architecture • Server cannot read your data</p> <p>🛡️ Zero-Knowledge Architecture • Server cannot read your data</p>
<p style="font-size: 0.8rem; margin-top: 8px; color: var(--text-muted);"> <p class="footer-subtitle">Built with privacy in mind. No tracking. No analytics. Open source.</p>
Built with privacy in mind. No tracking. No analytics. Open source.
</p>
<div class="footer-links"> <div class="footer-links">
<a href="https://github.com/TheGreatAzizi/Secure-Pastebin-Self-Hosted/" target="_blank" rel="noopener"> <a href="https://github.com/TheGreatAzizi/Secure-Pastebin-Self-Hosted/" target="_blank" rel="noopener" title="GitHub" aria-label="GitHub">
<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> <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>
GitHub
</a> </a>
<a href="https://x.com/the_azzi" target="_blank" rel="noopener"> <a href="https://x.com/the_azzi" target="_blank" rel="noopener" title="X" aria-label="X">
<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> <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>
@the_azzi </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> </a>
</div> </div>
<a class="footer-doc-link" href="/api/docs">API Docs</a>
<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>
</footer> </footer>
</div> </div>
+535 -134
View File
@@ -1,169 +1,570 @@
<?php <?php
// Secure Pastebin API - cPanel/PHP Version // Secure Pastebin - router + API
header('Content-Type: application/json');
// Database Config declare(strict_types=1);
define('DB_HOST', 'localhost');
define('DB_USER', ' --------- ');
define('DB_PASS', ' --------- ');
define('DB_NAME', ' --------- ');
const DB_HOST = 'localhost';
const DB_USER = ' --------- ';
const DB_PASS = ' --------- ';
const DB_NAME = ' --------- ';
// CORS const MIN_EXPIRY_SECONDS = 300;
header('Access-Control-Allow-Origin: *'); const MAX_EXPIRY_SECONDS = 31536000;
header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); const MAX_DATA_BYTES = 1024 * 1024 * 4;
header('Access-Control-Allow-Headers: Content-Type'); const API_VERSION = '1.2';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
http_response_code(200); $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
exit; $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) { if ($scriptDir !== '/' && strpos($path, $scriptDir) === 0) {
$path = substr($path, strlen($scriptDir)); $path = substr($path, strlen($scriptDir));
} $path = $path === '' ? '/' : $path;
if (empty($path)) $path = '/';
// API Routes
if ($path === '/api/create' && $method === 'POST') {
handleCreate($pdo);
exit;
} }
if (preg_match('/^\/api\/get\/(.+)$/', $path, $matches) && $method === 'GET') { $path = normalizeRoutePath($path);
handleGet($pdo, $matches[1]);
exit; $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 === '/api/docs' || $path === '/api-docs.php') {
if ($path === '/' || $path === '/index.html') {
header('Content-Type: text/html; charset=utf-8'); header('Content-Type: text/html; charset=utf-8');
readfile(__DIR__ . '/index.html'); require __DIR__ . '/api-docs.php';
exit; exit;
} }
if ($path === '/style.css') { if ($path === '/style.css') {
header('Content-Type: text/css'); serveFile(__DIR__ . '/style.css', 'text/css; charset=utf-8');
readfile(__DIR__ . '/style.css');
exit;
} }
if ($path === '/script.js') { if ($path === '/script.js') {
header('Content-Type: application/javascript'); serveFile(__DIR__ . '/script.js', 'application/javascript; charset=utf-8');
readfile(__DIR__ . '/script.js');
exit;
} }
if ($path === '/favicon.svg') { if ($path === '/favicon.svg') {
header('Content-Type: image/svg+xml'); 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:#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>'; 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; exit;
} }
http_response_code(404); if ($path === '/' || $path === '/index.html' || preg_match('#^/p/[^/]+$#', $path)) {
echo json_encode(array('error' => 'Not found')); serveFile(__DIR__ . '/index.html', 'text/html; charset=utf-8');
}
function handleCreate($pdo) { if ($path === '/api/options' && $method === 'GET') {
$input = json_decode(file_get_contents('php://input'), true); jsonResponse(buildApiOptions());
}
if (!$input) {
http_response_code(400); if ($path === '/api/health' && $method === 'GET') {
echo json_encode(array('error' => 'Invalid JSON')); $pdo = getPdo();
return; 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 '/';
} }
if ($dir[0] !== '/') {
$id = isset($input['id']) ? $input['id'] : ''; $dir = '/' . $dir;
$encryptedData = isset($input['encryptedData']) ? $input['encryptedData'] : null; }
$expiresIn = min(intval(isset($input['expiresIn']) ? $input['expiresIn'] : 3600), 2592000); 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']); $burnAfterRead = !empty($input['burnAfterRead']);
$hasPassword = !empty($input['hasPassword']); $hasPassword = !empty($input['hasPassword']);
$storedData = json_encode($encryptedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
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;
try { try {
$stmt = $pdo->prepare("INSERT INTO pastes (id, data, created_at, expires_at, burn_after_read, has_password, views) VALUES (?, ?, ?, ?, ?, ?, 0)"); $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)); $stmt->execute([$id, $storedData, time() * 1000, $expiry['expiresAt'], $burnAfterRead ? 1 : 0, $hasPassword ? 1 : 0]);
} catch (PDOException $e) {
echo json_encode(array( $message = $e->getCode() === '23000' ? 'A paste with this ID already exists' : 'Failed to save paste';
'success' => true, jsonResponse(['error' => $message, 'apiVersion' => API_VERSION], 500);
'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()));
} }
$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) { function buildEncryptedPayloadResponse(array $data): array
if (!preg_match('/^[a-f0-9]{32}$/', $id)) { {
http_response_code(400); $ivBinary = empty($data['iv']) ? '' : pack('C*', ...$data['iv']);
echo json_encode(array('error' => 'Invalid ID')); $cipherBinary = empty($data['data']) ? '' : pack('C*', ...$data['data']);
return;
} return [
'iv' => array_values($data['iv']),
try { 'data' => array_values($data['data']),
$stmt = $pdo->prepare("SELECT * FROM pastes WHERE id = ? AND expires_at > ?"); 'ivBase64' => base64urlEncode($ivBinary),
$stmt->execute(array($id, time())); 'dataBase64' => base64urlEncode($cipherBinary),
$paste = $stmt->fetch(PDO::FETCH_ASSOC); ];
}
if (!$paste) {
http_response_code(404); function handleGet(PDO $pdo, string $id): void
echo json_encode(array('error' => 'Paste not found or expired')); {
return; if (!isValidPasteId($id)) {
} jsonResponse(['error' => 'Invalid ID', 'apiVersion' => API_VERSION], 400);
}
$data = json_decode($paste['data'], true);
$stmt = $pdo->prepare('SELECT * FROM pastes WHERE id = ? AND expires_at > ?');
if ($paste['burn_after_read']) { $stmt->execute([$id, time()]);
$delStmt = $pdo->prepare("DELETE FROM pastes WHERE id = ?"); $paste = $stmt->fetch(PDO::FETCH_ASSOC);
$delStmt->execute(array($id));
} else { if (!$paste) {
$updStmt = $pdo->prepare("UPDATE pastes SET views = views + 1 WHERE id = ?"); jsonResponse(['error' => 'Paste not found or expired', 'apiVersion' => API_VERSION], 404);
$updStmt->execute(array($id)); }
}
$data = json_decode((string) $paste['data'], true);
echo json_encode(array( if (!is_array($data) || !isset($data['iv'], $data['data'])) {
'data' => $data, jsonResponse(['error' => 'Stored payload is invalid', 'apiVersion' => API_VERSION], 500);
'burnAfterRead' => (bool)$paste['burn_after_read'], }
'hasPassword' => (bool)$paste['has_password'],
'created' => intval($paste['created_at']) $responsePayload = buildEncryptedPayloadResponse($data);
));
if (!empty($paste['burn_after_read'])) {
} catch(PDOException $e) { $deleteStmt = $pdo->prepare('DELETE FROM pastes WHERE id = ?');
http_response_code(500); $deleteStmt->execute([$id]);
echo json_encode(array('error' => 'Server error')); } 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), '+/', '-_'), '=');
} }
+710 -92
View File
@@ -1,6 +1,12 @@
let pendingKey = null; let pendingKey = null;
let pendingData = 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) { function showError(msg) {
document.getElementById('errorDisplay').style.display = 'flex'; document.getElementById('errorDisplay').style.display = 'flex';
document.getElementById('errorMessage').textContent = msg; document.getElementById('errorMessage').textContent = msg;
@@ -12,34 +18,229 @@ function clearError() {
function isRTL(text) { function isRTL(text) {
const rtlRegex = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF\uFB50-\uFDFF\uFE70-\uFEFF]/; 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'); const contentTextarea = document.getElementById('content');
if (contentTextarea) { if (contentTextarea) {
contentTextarea.addEventListener('input', function(e) { contentTextarea.addEventListener('input', updateTextareaDirection);
const text = e.target.value; }
if (isRTL(text)) {
e.target.style.direction = 'rtl'; const subjectInput = document.getElementById('subject');
e.target.style.fontFamily = "'Vazirmatn', sans-serif"; if (subjectInput) {
} else { subjectInput.addEventListener('input', function(e) {
e.target.style.direction = 'ltr'; applyDirectionalStyles(e.target, e.target.value, FONT_STACK_SANS, FONT_STACK_SANS);
e.target.style.fontFamily = "'JetBrains Mono', monospace";
}
}); });
} }
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() { function togglePassword() {
const enabled = document.getElementById('enablePassword').checked; const enabled = document.getElementById('enablePassword').checked;
const wrapper = document.getElementById('passwordWrapper'); const wrapper = document.getElementById('passwordWrapper');
wrapper.classList.toggle('show', enabled); 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 = { const Base64 = {
encode(buf) { encode(buf) {
const bytes = new Uint8Array(buf); const bytes = new Uint8Array(buf);
let bin = ''; 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, ''); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}, },
decode(str) { decode(str) {
@@ -67,19 +268,294 @@ async function deriveKeyFromPassword(password, salt) {
} }
function genId() { function genId() {
const arr = new Uint8Array(16); const arr = new Uint8Array(12);
crypto.getRandomValues(arr); 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttribute(text) {
return escapeHtml(text).replace(/`/g, '&#96;');
}
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() { async function createPaste() {
clearError(); clearError();
const subject = document.getElementById('subject').value.trim();
const content = document.getElementById('content').value.trim(); const content = document.getElementById('content').value.trim();
if (!content) return showError('Please enter content to encrypt'); if (!content) return showError('Please enter content to encrypt');
const hasPassword = document.getElementById('enablePassword').checked; const hasPassword = document.getElementById('enablePassword').checked;
const password = document.getElementById('passwordInput').value; const password = document.getElementById('passwordInput').value;
if (hasPassword && password.length < 4) { if (hasPassword && password.length < 4) {
return showError('Password must be at least 4 characters'); return showError('Password must be at least 4 characters');
} }
@@ -90,8 +566,10 @@ async function createPaste() {
btnText.innerHTML = '<span class="loading"></span> Encrypting...'; btnText.innerHTML = '<span class="loading"></span> Encrypting...';
try { try {
let key, keyToExport; const expiry = getExpiryPayload();
let key;
let keyToExport;
if (hasPassword) { if (hasPassword) {
const salt = crypto.getRandomValues(new Uint8Array(16)); const salt = crypto.getRandomValues(new Uint8Array(16));
key = await deriveKeyFromPassword(password, salt); 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']); key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
keyToExport = await crypto.subtle.exportKey('raw', key); keyToExport = await crypto.subtle.exportKey('raw', key);
} }
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt( const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, { name: 'AES-GCM', iv },
key, key,
new TextEncoder().encode(content) new TextEncoder().encode(buildEncryptedPayload(subject, content))
); );
const id = genId(); const id = genId();
const payload = { const payload = {
iv: Array.from(iv), iv: Array.from(iv),
data: Array.from(new Uint8Array(encrypted)) data: Array.from(new Uint8Array(encrypted))
}; };
const res = await fetch('/api/create', { const res = await fetch('/api/pastes', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
id, id,
encryptedData: payload, encryptedData: payload,
expiresIn: parseInt(document.getElementById('expiresIn').value), expiresIn: expiry.expiresIn,
customExpiresAt: expiry.expiresAt,
burnAfterRead: document.getElementById('burnAfterRead').checked, burnAfterRead: document.getElementById('burnAfterRead').checked,
hasPassword: hasPassword hasPassword: hasPassword
}) })
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok) throw new Error(result.error || 'Server error'); if (!res.ok) throw new Error(result.error || 'Server error');
let url; const keyFragment = Base64.encode(keyToExport);
if (hasPassword) { const fullShareUrl = buildFullShareUrl(id, keyFragment, hasPassword);
url = `${location.origin}/#${id}:${Base64.encode(keyToExport)}:pwd`; const shortShareUrl = buildShortShareUrl(id, keyFragment, hasPassword);
} else { document.getElementById('shareUrl').value = fullShareUrl;
url = `${location.origin}/#${id}:${Base64.encode(keyToExport)}`; document.getElementById('shareUrl').dataset.shortUrl = shortShareUrl;
} document.getElementById('expiryTime').textContent = expiry.displayDate.toLocaleString();
document.getElementById('shareUrl').value = url;
document.getElementById('expiryTime').textContent = new Date(Date.now() + parseInt(document.getElementById('expiresIn').value) * 1000).toLocaleString();
document.getElementById('passwordNotice').style.display = hasPassword ? 'flex' : 'none'; document.getElementById('passwordNotice').style.display = hasPassword ? 'flex' : 'none';
document.getElementById('resultBox').classList.add('show'); document.getElementById('resultBox').classList.add('show');
document.getElementById('content').value = ''; document.getElementById('resultBox').scrollIntoView({ behavior: 'smooth', block: 'start' });
resetCreateForm();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} finally { } finally {
@@ -150,49 +627,76 @@ async function createPaste() {
} }
} }
async function decryptPaste() { function parseLocationForPaste() {
const hash = location.hash.slice(1); const hash = location.hash.slice(1);
if (!hash.includes(':')) return; const pathMatch = location.pathname.match(/^\/p\/([^/]+)\/?$/);
const parts = hash.split(':'); if (pathMatch && hash) {
const id = parts[0]; const id = decodeURIComponent(pathMatch[1]);
const keyData = parts[1]; const isPasswordProtected = hash.endsWith(':pwd');
const isPasswordProtected = parts[2] === '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 { try {
const res = await fetch(`/api/get/${id}`); const res = await fetch(`/api/get/${encodeURIComponent(pasteRef.id)}`);
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error); if (!res.ok) throw new Error(data.error);
if (data.hasPassword || isPasswordProtected) { if (data.hasPassword || pasteRef.isPasswordProtected) {
pendingKey = keyData; pendingKey = pasteRef.keyData;
pendingData = data; pendingData = data;
document.getElementById('createView').style.display = 'none'; document.getElementById('createView').style.display = 'none';
document.getElementById('passwordPrompt').classList.add('show'); document.getElementById('passwordPrompt').classList.add('show');
document.getElementById('passwordPrompt').scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
} }
await performDecryption(keyData, data); await performDecryption(pasteRef.keyData, data);
} catch (err) { } catch (err) {
showError('Failed: ' + err.message); showError('Failed: ' + err.message);
setTimeout(() => location.href = '/', 3000); setTimeout(() => {
location.href = '/';
}, 3000);
} }
} }
async function decryptWithPassword() { async function decryptWithPassword() {
const password = document.getElementById('decryptPassword').value; const password = document.getElementById('decryptPassword').value;
if (!password) return; if (!password) return;
const btn = document.querySelector('#passwordPrompt .btn'); const btn = document.querySelector('#passwordPrompt .btn');
const btnText = document.getElementById('decryptBtnText'); const btnText = document.getElementById('decryptBtnText');
btn.disabled = true; btn.disabled = true;
btnText.innerHTML = '<span class="loading"></span> Decrypting...'; btnText.innerHTML = '<span class="loading"></span> Decrypting...';
try { try {
const salt = Base64.decode(pendingKey); const salt = Base64.decode(pendingKey);
const key = await deriveKeyFromPassword(password, salt); const key = await deriveKeyFromPassword(password, salt);
await performDecryption(key, pendingData, true); await performDecryption(key, pendingData, true);
document.getElementById('passwordError').style.display = 'none';
} catch (err) { } catch (err) {
document.getElementById('passwordError').style.display = 'block'; document.getElementById('passwordError').style.display = 'block';
btn.disabled = false; btn.disabled = false;
@@ -202,7 +706,7 @@ async function decryptWithPassword() {
async function performDecryption(keyOrData, data, isKeyObject = false) { async function performDecryption(keyOrData, data, isKeyObject = false) {
let key; let key;
if (isKeyObject) { if (isKeyObject) {
key = keyOrData; key = keyOrData;
} else { } else {
@@ -211,45 +715,159 @@ async function performDecryption(keyOrData, data, isKeyObject = false) {
'raw', keyRaw, { name: 'AES-GCM', length: 256 }, false, ['decrypt'] 'raw', keyRaw, { name: 'AES-GCM', length: 256 }, false, ['decrypt']
); );
} }
const decrypted = await crypto.subtle.decrypt( const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(data.data.iv) }, { name: 'AES-GCM', iv: new Uint8Array(data.data.iv) },
key, key,
new Uint8Array(data.data.data) new Uint8Array(data.data.data)
); );
const text = new TextDecoder().decode(decrypted); const decoded = new TextDecoder().decode(decrypted);
const payload = parseDecryptedPayload(decoded);
document.getElementById('passwordPrompt').classList.remove('show'); showDecryptedContent(payload, data.burnAfterRead);
document.getElementById('decryptView').classList.add('show');
history.replaceState(null, '', location.pathname + location.search);
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, ' ');
} }
function copyUrl() { async function copyTextToClipboard(text) {
const inp = document.getElementById('shareUrl'); if (!text) return;
inp.select();
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'); document.execCommand('copy');
const btn = document.querySelector('.btn-copy'); document.body.removeChild(temp);
btn.textContent = '✅ Copied!';
setTimeout(() => btn.textContent = '📋 Copy', 2000);
} }
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', () => { window.addEventListener('load', () => {
if (location.hash.length > 1) decryptPaste(); setCustomExpiryBounds();
resetPasswordStrength();
if (location.hash.length > 0 || /^\/p\//.test(location.pathname)) {
decryptPaste();
}
}); });
+962 -475
View File
File diff suppressed because it is too large Load Diff