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