Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Local development
clickhouse-data/
config.local.js

# Docker
docker-compose.override.yml
153 changes: 153 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Local Development Setup

This guide explains how to run Pastila locally with a Docker-based ClickHouse instance.

## Prerequisites

- Docker and Docker Compose
- Python 3 (for the HTTP server)

## Quick Start

1. **Start ClickHouse in Docker:**
```bash
docker-compose up -d
```

This will:
- Start a local ClickHouse server on ports 18123 (HTTP) and 19000 (native)
- Automatically run the initialization script (`init-db.sql`)
- Create the database schema, users, quotas, and views

2. **Wait for ClickHouse to be ready:**
```bash
# Check if ClickHouse is healthy
docker-compose ps

# Or watch the logs
docker-compose logs -f clickhouse
```

3. **Start the local web server:**
```bash
python3 -m http.server 8080
```

4. **Open in browser:**
```
http://localhost:8080
```

The application will automatically detect it's running locally and connect to `http://localhost:18123`.

## Configuration

### Automatic Environment Detection

The `config.js` file automatically detects if you're running locally:
- **Local development** (localhost/127.0.0.1): Uses `http://localhost:18123/?user=paste`
- **Production** (pastila.nl): Uses the ClickHouse Cloud URL

### Manual Override

You can manually override the ClickHouse URL in the browser console:

```javascript
localStorage.setItem('clickhouse_url', 'http://your-custom-url:18123/?user=paste');
// Then reload the page
```

To clear the override:
```javascript
localStorage.removeItem('clickhouse_url');
```

## Useful Commands

### Check ClickHouse is running
```bash
curl http://localhost:18123/
# Should return "Ok."
```

### Query the database directly
```bash
docker-compose exec clickhouse clickhouse-client
```

```sql
-- Show databases
SHOW DATABASES;

-- Show tables in paste database
SHOW TABLES FROM paste;

-- View recent pastes
SELECT fingerprint, hash, length(content) as size, time
FROM paste.data
ORDER BY time DESC
LIMIT 10;
```

### View logs
```bash
docker-compose logs -f clickhouse
```

### Stop services
```bash
docker-compose down
```

### Clean up (removes all data)
```bash
docker-compose down -v
rm -rf clickhouse-data/
```

## Database Schema

The database schema is defined in `init-db.sql` and includes:
- **paste.data** table with constraints for hash validation, size limits, and spam prevention
- **paste.data_view** for access control
- **paste** and **paste_sys** users with specific permissions
- **Quotas** for rate limiting

## Differences from Production

The local setup uses:
- **MergeTree** engine instead of **ReplicatedMergeTree** (single node)
- **No SSL** (http:// instead of https://)
- **Docker-based** ClickHouse instead of ClickHouse Cloud

## Troubleshooting

### Port 18123 or 19000 already in use
The default setup uses ports 18123 (HTTP) and 19000 (native) to avoid conflicts with other services.

If you need to use different ports, modify `docker-compose.yml`:
```yaml
ports:
- "28123:8123" # Use port 28123 instead
- "29000:9000" # Use port 29000 instead
```

Then update your manual override:
```javascript
localStorage.setItem('clickhouse_url', 'http://localhost:28123/?user=paste');
```

### Database initialization fails
Check the logs:
```bash
docker-compose logs clickhouse
```

You can manually re-run the initialization:
```bash
docker-compose exec clickhouse clickhouse-client < init-db.sql
```

### CORS errors
Make sure you're accessing via `http://localhost:8080` (or similar) and not `file://`.
The ClickHouse user has `add_http_cors_header = 1` enabled.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Features:
previous version of the data is available by clicking the "back" button in bottom right corner;
- Added Encryption option to encrypt data in browser before inserting into ClickHouse database,
encryption key is kept in anchor tag which never leaves the user's browser.
- Burn after reading: documents can be viewed only once, then automatically deleted - all intermediate versions are also deleted when enabling this feature.

## Motivation

Expand All @@ -35,6 +36,8 @@ and unusual scenarios to find potential flaws and to explore new possibilities.

## Contributing

For local development setup with Docker, see [CONTRIBUTING.md](CONTRIBUTING.md).

Please send a pull request to
https://github.com/ClickHouse/pastila

Expand Down Expand Up @@ -82,6 +85,7 @@ CREATE TABLE paste.data
prev_fingerprint_hex String EPHEMERAL '',
prev_hash_hex String EPHEMERAL '',
is_encrypted UInt8,
burn_after_reading UInt8 DEFAULT 0,

CONSTRAINT length CHECK length(content) < 10 * 1024 * 1024,
CONSTRAINT hash_is_correct CHECK sipHash128(content) = reinterpretAsFixedString(hash),
Expand Down Expand Up @@ -126,8 +130,9 @@ TO paste;

CREATE VIEW paste.data_view DEFINER = 'paste_sys' AS SELECT * FROM paste.data WHERE fingerprint = reinterpretAsUInt32(unhex({fingerprint:String})) AND hash = reinterpretAsUInt128(unhex({hash:String})) ORDER BY time LIMIT 1;

-- Grant permissions
GRANT INSERT ON paste.data TO paste;
GRANT SELECT ON paste.data_view TO paste;

GRANT ALTER UPDATE ON paste.data TO paste;
GRANT SELECT ON paste.data TO paste_sys;
```
26 changes: 26 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// ClickHouse Configuration
// This file is loaded before the main script and configures the ClickHouse URL

// Auto-detect environment based on hostname
const isLocalDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '';

// Configure ClickHouse URL based on environment
// For local development, this points to local Docker instance
// For production, this points to ClickHouse Cloud
const detectedUrl = isLocalDevelopment
? "http://localhost:18123/?user=paste"
: "https://uzg8q0g12h.eu-central-1.aws.clickhouse.cloud/?user=paste";

// You can also manually override by setting localStorage:
// localStorage.setItem('clickhouse_url', 'http://your-custom-url:8123/?user=paste');
const manual_override = localStorage.getItem('clickhouse_url');
if (manual_override) {
console.log('Using manual ClickHouse URL override:', manual_override);
}

// Export the configured URL
window.CLICKHOUSE_URL = manual_override || detectedUrl;

console.log('ClickHouse URL configured:', window.CLICKHOUSE_URL);
23 changes: 23 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
version: '3.8'

services:
clickhouse:
image: clickhouse/clickhouse-server:latest
container_name: pastila-clickhouse
ports:
- "18123:8123" # HTTP interface
- "19000:9000" # Native protocol
volumes:
- ./clickhouse-data:/var/lib/clickhouse
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
environment:
CLICKHOUSE_DB: paste
ulimits:
nofile:
soft: 262144
hard: 262144
healthcheck:
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
interval: 10s
timeout: 5s
retries: 5
69 changes: 64 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<meta charset="UTF-8">
<link id="icon" rel="icon" href="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=">
<title>Paste</title>
<script src="config.js"></script>
<style>
html, body, textarea {
width: 100%;
Expand Down Expand Up @@ -123,6 +124,7 @@
<a class="about" href="https://github.com/ClickHouse/pastila">About</a>
<label><input id="encryption" checked="true" type="checkbox">&nbsp;end-to-end encryption</input></label>
<label><input id="wrap" checked="true" type="checkbox">&nbsp;text wrap</input></label>
<label><input id="burn" type="checkbox">&nbsp;burn after reading</input></label>
</div>

<textarea id="data" spellcheck="false" autofocus></textarea>
Expand All @@ -133,7 +135,7 @@
<a class="status" id="back">⎌</a>
<script type="text/javascript">

const clickhouse_url = "https://uzg8q0g12h.eu-central-1.aws.clickhouse.cloud/?user=paste";
const clickhouse_url = window.CLICKHOUSE_URL || "https://uzg8q0g12h.eu-central-1.aws.clickhouse.cloud/?user=paste";

const encoder = new TextEncoder;
const decoder = new TextDecoder;
Expand Down Expand Up @@ -316,7 +318,7 @@ <h2 style="margin-top:0;color:#c00;">Notice</h2>

const response = await fetch(
clickhouse_url,
{ method: "POST", body: `SELECT content, is_encrypted, lower(hex(reinterpretAsFixedString(prev_hash))) AS prev_hash, lower(hex(reinterpretAsFixedString(prev_fingerprint))) AS prev_fingerprint FROM data_view(fingerprint = '${fingerprint}', hash = '${hash}') FORMAT JSON` });
{ method: "POST", body: `SELECT content, is_encrypted, burn_after_reading, lower(hex(reinterpretAsFixedString(prev_hash))) AS prev_hash, lower(hex(reinterpretAsFixedString(prev_fingerprint))) AS prev_fingerprint FROM data_view(fingerprint = '${fingerprint}', hash = '${hash}') FORMAT JSON` });

function onError() {
show(error);
Expand All @@ -330,6 +332,7 @@ <h2 style="margin-top:0;color:#c00;">Notice</h2>
const result = json.data[0];
let content = result.content;
const is_encrypted = result.is_encrypted;
const burn_after_reading = result.burn_after_reading;

prev_hash = result.prev_hash;
prev_fingerprint = result.prev_fingerprint;
Expand All @@ -347,6 +350,25 @@ <h2 style="margin-top:0;color:#c00;">Notice</h2>
// Set the content in any case (even if rendering as HTML/MD/QR, we fall back to plaintext view anyway).
document.getElementById('data').value = content;

// If burn after reading, make textarea read-only and disable saving
if (burn_after_reading) {
const textarea = document.getElementById('data');
textarea.setAttribute('readonly', 'readonly');
textarea.style.backgroundColor = '#2a1a1a';
textarea.style.color = '#ff9999';
textarea.style.border = '2px solid #ff0000';

// Disable checkboxes
document.getElementById('burn').disabled = true;
document.getElementById('encryption').disabled = true;
document.getElementById('wrap').disabled = true;

// Show warning
if (!type || type === '') {
alert('⚠️ This document will be burned after reading. Editing is disabled.');
}
}

if (type === '.html' || type == '.htm') {
if (document.documentURI.indexOf("96d540560fc9de34f2bf24d5c2b9d62a.html") > 0 ||confirm("⚠️ The pastila link will render as html, which may be unsafe. Press Cancel to view as plain text.")) {
document.open();
Expand Down Expand Up @@ -381,21 +403,38 @@ <h2 style="margin-top:0;color:#c00;">Notice</h2>
}

setFavicon();

// If burn after reading is enabled, delete immediately after loading
if (burn_after_reading) {
// Fire and forget - don't await, delete happens in background
fetch(clickhouse_url, {
method: "POST",
body: `DELETE FROM data WHERE fingerprint = reinterpretAsUInt32(unhex('${fingerprint}')) AND hash = reinterpretAsUInt128(unhex('${hash}'))`
}).catch(err => console.error('Failed to delete burn-after-reading document:', err));
}
}


async function save() {
// Don't save if textarea is readonly (burn-after-reading view mode)
const textarea = document.getElementById('data');
if (textarea.hasAttribute('readonly')) {
return;
}

const my_request_num = ++request_num;

show(wait);

prev_fingerprint = curr_fingerprint;
prev_hash = curr_hash;

let text = document.getElementById('data').value;
let text = textarea.value;
let anchor = '';

is_encrypted = encryption.checked;
const burn_after_reading = document.getElementById('burn').checked;

if (is_encrypted) {
const key_bytes = crypto.getRandomValues(new Uint8Array(16));
text = await encrypt(text, key_bytes);
Expand All @@ -410,14 +449,15 @@ <h2 style="margin-top:0;color:#c00;">Notice</h2>
clickhouse_url,
{
method: "POST",
body: "INSERT INTO data (fingerprint_hex, hash_hex, prev_fingerprint_hex, prev_hash_hex, content, is_encrypted) FORMAT JSONEachRow " + JSON.stringify(
body: "INSERT INTO data (fingerprint_hex, hash_hex, prev_fingerprint_hex, prev_hash_hex, content, is_encrypted, burn_after_reading) FORMAT JSONEachRow " + JSON.stringify(
{
fingerprint_hex: curr_fingerprint,
hash_hex: curr_hash,
prev_fingerprint_hex: prev_fingerprint,
prev_hash_hex: prev_hash,
content: text,
is_encrypted: is_encrypted
is_encrypted: is_encrypted,
burn_after_reading: burn_after_reading ? 1 : 0
})
});

Expand Down Expand Up @@ -473,6 +513,25 @@ <h2 style="margin-top:0;color:#c00;">Notice</h2>

document.getElementById('data').addEventListener('input', event => save());

// Handle burn after reading checkbox
document.getElementById('burn').addEventListener('change', async (event) => {
if (event.target.checked) {
// Delete ALL previous versions with the same fingerprint (includes all intermediate edits)
if (curr_fingerprint) {
await fetch(clickhouse_url, {
method: "POST",
body: `DELETE FROM data WHERE fingerprint = reinterpretAsUInt32(unhex('${curr_fingerprint}'))`
});
}

// Save with burn flag (and all future saves will also have burn flag)
await save();

// Show message
alert('🔥 Burn-after-reading enabled. All intermediate versions deleted. All future edits will also burn after reading.');
}
});

back.addEventListener('click', (event) => {
load(prev_fingerprint, prev_hash);
history.pushState(null, null, location.pathname.replace(/(\?.+)?$/, `?${curr_fingerprint}/${curr_hash}#${anchorkey}`));
Expand Down
Loading