A minimal URL shortener built with pure Python (no dependencies).
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/ |
public | Short plain-text info message |
GET / HEAD |
/<code> |
public | 302 Found redirect to the stored URL |
POST |
/ |
localhost | Create a short URL (JSON body) |
DELETE |
/<code> |
localhost | Remove a short URL |
Localhost-only methods are enforced at the Apache layer via <LimitExcept GET HEAD>.
/var/www/pptiny/
├── app.py # WSGI application
├── wsgi.py # WSGI entry point
└── init_db.py # One-time database init
/var/lib/pptiny/
└── urls.db # SQLite database (outside DocumentRoot)
| Variable | Purpose | Default |
|---|---|---|
PPTINY_DB |
SQLite DB path | /var/lib/pptiny/urls.db |
PPTINY_HOST |
Canonical host for short_url in POST responses (not reflected from Host) |
SERVER_NAME, then HTTP_HOST |
PPTINY_ALLOW_PRIVATE |
Set to 1 to allow loopback / private / link-local IP-literal destinations |
unset |
None — Python standard library only.
sudo mkdir -p /var/lib/pptiny
sudo chown www-data:www-data /var/lib/pptiny
sudo -u www-data python3 init_db.pyCreates the DB dir, enables journal_mode=WAL, and creates the schema + index.
sudo apt-get install libapache2-mod-wsgi-py3
sudo a2enmod wsgi sslSee apache-config.example. Key points:
:80redirects to:443— no plaintext service:443terminates TLS and runs the WSGI appSetEnv PPTINY_HOST pptiny.twistedslinky.orgpins the canonical hostname<LimitExcept GET HEAD>restricts POST/DELETE (and any other method) to localhostLimitRequestBody 65536caps request bodies at the Apache layer
Obtain a cert (e.g. via certbot), then:
sudo systemctl restart apache2The application directory should not be writable by the Apache user:
sudo chown -R root:root /var/www/pptiny/
sudo chmod 755 /var/www/pptiny/Only /var/lib/pptiny/ needs www-data write access (set in step 2).
curl -X POST https://localhost/ \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/very/long/url"}'Response: 201 Created with a Location: /<code> header and JSON body:
{
"short_url": "https://pptiny.twistedslinky.org/abc12345",
"short_code": "abc12345",
"long_url": "https://example.com/very/long/url"
}curl -L https://pptiny.twistedslinky.org/abc12345Returns 302 Found with Cache-Control: private, max-age=300 and Referrer-Policy: no-referrer. The short TTL means abuse takedowns propagate to browser caches within ~5 minutes.
curl -X DELETE https://localhost/abc12345Returns 204 No Content on success, 404 if the code is unknown.
sudo -u www-data python3 -c "import sqlite3; db=sqlite3.connect('/var/lib/pptiny/urls.db'); [print(f'{code}: {url}') for code, url in db.execute('SELECT short_code, long_url FROM urls')]"| Code | When |
|---|---|
200 OK |
GET / info message |
201 Created |
Short URL created |
204 No Content |
Delete succeeded |
302 Found |
GET /<code> redirect |
400 Bad Request |
Invalid JSON body or invalid URL |
404 Not Found |
Unknown short code |
405 Method Not Allowed |
Method outside GET / HEAD / POST / DELETE |
413 Payload Too Large |
POST body exceeds 4 KiB |
415 Unsupported Media Type |
POST Content-Type is not application/json |
500 Internal Server Error |
Unhandled error, or no unique code found after 10 attempts |
503 Service Unavailable |
SQLite operational error (usually lock contention) |
507 Insufficient Storage |
DB row cap (MAX_ROWS) reached |
- TLS-only:
:80redirects to:443; no plaintext service - Non-GET restricted to localhost: Apache-enforced for POST, DELETE, and any other method
- CSPRNG short codes: 8-char alphanumeric via
secrets.choice - URL validation:
urllib.parse—http/httpsscheme, non-empty host, no control characters, 2048-char length cap - Private IP-literal block: URLs whose host is a loopback / RFC1918 / link-local / reserved / multicast IP literal are rejected. Hostnames are not resolved — no DNS lookups on the POST path. Override with
PPTINY_ALLOW_PRIVATE=1 - Body size cap: 4 KiB at the app layer (
413on overflow), 64 KiB at the Apache layer - Canonical host: POST response
short_urlusesPPTINY_HOST, not the client-suppliedHostheader - Redirect caching:
302 Found+ shortCache-Controllets removed codes expire from browser caches quickly - TOCTOU-safe row cap: count + insert wrapped in
BEGIN IMMEDIATE X-Content-Type-Options: nosniffon every response- DB outside DocumentRoot: cannot be served by a misconfigured Apache
The restriction model assumes only trusted processes on the host can reach the WSGI app over localhost. Anyone who can POST — a neighboring service with SSRF, a compromised local account, a careless cron job — can mint redirects on the shortener's domain pointing anywhere on the public internet. Treat localhost POST access as equivalent to the ability to create phishing links on your domain.
Caveat on the IP-literal block: it only catches URLs where the host is literally an internal IP (e.g. http://10.0.0.1/). A URL whose hostname happens to resolve to an internal IP (http://internal.corp/) is not rejected — we don't do DNS lookups on the POST path. Internal-endpoint phishing via custom DNS is out of scope for this app; if it matters for your deployment, add network-level egress controls on clients or a destination-domain allowlist.
If localhost POST access itself is too broad, add an authentication layer in front of POST/DELETE.
- app.py: WSGI application — routing and business logic
- wsgi.py: Minimal glue between Apache mod_wsgi and the application
- init_db.py: One-time init — creates DB dir, enables WAL, creates schema + index
- urls.db: SQLite database, stored outside the web root