Upload files to "sk1"

This commit is contained in:
Nitheesh Kumar Subramanian 2026-05-12 20:26:48 +00:00
parent 59bd43b8ca
commit 88413f8d95
13 changed files with 1129 additions and 0 deletions

16
sk1/.env.example Normal file
View File

@ -0,0 +1,16 @@
# ─── Database ────────────────────────────────────────────────────────────────
DB_NAME=shortlink
DB_USER=shortlink_user
DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD_123
# ─── Application ─────────────────────────────────────────────────────────────
BASE_URL=https://yourdomain.com
SECRET_KEY=CHANGE_THIS_TO_A_RANDOM_32_CHAR_STRING
ADMIN_TOKEN=CHANGE_THIS_ADMIN_TOKEN
# ─── Deployment (used by prepare-app.sh) ─────────────────────────────────────
DOMAIN=yourdomain.com
EMAIL=your@email.com
# ─── Backup (optional) ───────────────────────────────────────────────────────
BACKUP_DIR=./backups

8
sk1/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Never commit secrets or generated files
.env
nginx/nginx.conf
backups/
*.sql.gz
__pycache__/
*.pyc
.DS_Store

15
sk1/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies first (better layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY main.py .
EXPOSE 8000
# Run with auto-reload disabled in production
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

233
sk1/README.md Normal file
View File

@ -0,0 +1,233 @@
# ShortLink — URL Shortener with Analytics
## Description
ShortLink is a self-hosted URL shortening service with a real-time analytics dashboard.
Users paste a long URL, receive a short link (e.g. `https://yourdomain.com/s/abc123`),
and can track how many times the link was visited, when, and from where.
The application targets teams and developers who need a private, self-controlled link shortener
without relying on third-party services like bit.ly.
---
## Cloud Infrastructure
**Cloud provider:** Google Cloud Platform (GCP)
| Component | GCP Service / Tool | Purpose |
|---|---|---|
| Virtual machine | Compute Engine e2-small | Hosts all containers |
| Static IP | Compute Engine External IP | Fixed address for DNS |
| Firewall | VPC Firewall rules | Allow HTTP (80) and HTTPS (443) |
| DNS | Any registrar → A record | Maps domain to the VM IP |
| SSL certificate | Let's Encrypt via Certbot | Free automatic HTTPS certificate |
| Container runtime | Docker Engine | Runs all application containers |
| Orchestration | Docker Compose | Manages multi-container lifecycle |
**Docker objects:**
| Container | Image | Role |
|---|---|---|
| `shortlink_nginx` | `nginx:alpine` | Reverse proxy, SSL termination, serves static frontend |
| `shortlink_backend` | Built from `./backend/Dockerfile` | FastAPI REST API, business logic |
| `shortlink_db` | `postgres:15-alpine` | Relational database (links + visits tables) |
**Named volumes:**
| Volume | Mounted in | Contents |
|---|---|---|
| `postgres_data` | `/var/lib/postgresql/data` | All database data (persistent) |
| `nginx_logs` | `/var/log/nginx` | Access and error logs |
**Network:** All three containers share a Docker bridge network (`app_network`) so they can reach each other by container name (e.g. `backend:8000`, `db:5432`).
---
## Cost Analysis (1 000 daily users, 50 GB data)
Estimated traffic: ~1 000 requests/day → ~30 000/month (light load).
| Resource | Specification | Monthly price | Annual price |
|---|---|---|---|
| Compute Engine e2-small | 2 vCPU, 2 GB RAM, region us-central1 | $13.60 | $163 |
| Persistent disk (SSD boot) | 30 GB | $5.10 | $61 |
| Additional SSD (database) | 50 GB | $8.50 | $102 |
| External IP address (static) | 1 address | $7.30 | $88 |
| Egress traffic | ~50 GB/month outbound | $4.50 | $54 |
| SSL certificate | Let's Encrypt | $0 | $0 |
| **Total** | | **~$39** | **~$468** |
> Prices based on GCP us-central1 on-demand pricing (2024). Costs can be reduced ~37% by using a 1-year committed-use discount.
> Snapshot for backup: ~$0.026/GB/month × 50 GB = $1.30/month extra.
---
## Files
```
sk1/
├── prepare-app.sh # Deploy: installs Docker, Certbot, gets SSL cert, starts app
├── remove-app.sh # Teardown: stops and removes all containers and volumes
├── docker-compose.yml # Defines all three services, volumes and network
├── .env.example # Template for required environment variables (copy to .env)
├── README.md # This file
├── backend/
│ ├── Dockerfile # Builds Python 3.11-slim image with FastAPI
│ ├── requirements.txt # Python dependencies (fastapi, uvicorn, psycopg2)
│ └── main.py # REST API: POST /api/shorten, GET /api/stats,
│ # DELETE /api/links/{code}, GET /s/{code}
├── init-db/
│ └── init.sql # Creates tables (links, visits) and indexes on first run
├── nginx/
│ ├── nginx.conf.template # Nginx config with DOMAIN_PLACEHOLDER (filled by prepare-app.sh)
│ └── html/
│ └── index.html # Single-page frontend (vanilla JS, dark theme)
└── scripts/
└── backup.sh # Dumps the database to a gzip file, keeps last 7
```
---
## Configuration
All runtime secrets and settings live in `.env` (never committed to Git).
| Variable | Description |
|---|---|
| `DB_NAME` | PostgreSQL database name |
| `DB_USER` | PostgreSQL user |
| `DB_PASSWORD` | PostgreSQL password (secret) |
| `BASE_URL` | Public URL shown in short links (e.g. `https://yourdomain.com`) |
| `SECRET_KEY` | Application secret for future JWT use (secret) |
| `ADMIN_TOKEN` | Bearer token required to delete links via API (secret) |
| `DOMAIN` | Domain name used by Certbot and nginx (e.g. `yourdomain.com`) |
| `EMAIL` | Email for Let's Encrypt certificate notifications |
| `BACKUP_DIR` | Where backups are written (default: `./backups`) |
`docker-compose.yml` reads these via `${VAR}` substitution and passes them as container environment variables. No secrets appear in any source file or in Git.
---
## Deployment Instructions
### Prerequisites
- A GCP Compute Engine VM running Ubuntu 22.04 LTS (e2-small or larger)
- An external static IP assigned to the VM
- Firewall rules allowing TCP 80 and TCP 443
- A domain name with an A record pointing to the VM IP
- SSH access to the VM
### Steps
```bash
# 1. SSH into the VM
gcloud compute ssh <INSTANCE_NAME> --zone <ZONE>
# 2. Clone or copy the project to the VM
git clone <YOUR_GIT_REPO_URL>
cd sk1
# 3. Create and fill in the environment file
cp .env.example .env
nano .env # fill in all values
# 4. Make scripts executable
chmod +x prepare-app.sh remove-app.sh scripts/backup.sh
# 5. Run the deployment script
./prepare-app.sh
# Done — visit https://<DOMAIN>
```
### Using the application
1. Open `https://<DOMAIN>` in a web browser.
2. Paste any URL into the input field and click **Shorten**.
3. Copy the short link and share it — every click is tracked.
4. The **Analytics** table below shows all links with visit counts.
5. To use a custom short code, click **⚙ Custom code** before shortening.
6. To delete a link, click **Del** in the table and enter the `ADMIN_TOKEN`.
---
## Backup
```bash
# Run the backup script (from the sk1 directory)
./scripts/backup.sh
# Backups are saved as ./backups/shortlink_YYYYMMDD_HHMMSS.sql.gz
# The last 7 backups are kept automatically.
# To restore:
gunzip -c backups/shortlink_<timestamp>.sql.gz \
| docker exec -i shortlink_db psql -U "$DB_USER" -d "$DB_NAME"
```
---
## Viewing Access Logs
```bash
# Live nginx access log (HTTP requests from the internet)
docker exec shortlink_nginx tail -f /var/log/nginx/access.log
# Or from the named volume on the host
docker run --rm -v shortlink_nginx_logs:/logs alpine tail -f /logs/access.log
# Last 100 lines of access log
docker exec shortlink_nginx tail -100 /var/log/nginx/access.log
# Error log
docker exec shortlink_nginx tail -f /var/log/nginx/error.log
# All container logs (backend API logs including each redirect)
docker compose logs -f
```
---
## Stopping / Removing the Application
```bash
./remove-app.sh
```
This stops and removes containers, the Docker network, and named volumes (database is deleted). SSL certificates are preserved.
---
## Script Conditions
### prepare-app.sh
- Must be run on a **Ubuntu 22.04** server (GCP Compute Engine VM or equivalent)
- The VM must have **ports 80 and 443 open** in the firewall
- **DNS** must be configured: the domain's A record must point to the VM's external IP **before running the script** (Certbot validates this)
- `.env` file must exist with all required variables filled in
- Internet access required (to pull Docker images, install packages, contact Let's Encrypt)
- The script is idempotent: safe to run again if interrupted
### remove-app.sh
- Must be run from the `sk1` directory on the same VM
- Docker and Docker Compose must be installed
---
## External Resources
| Resource | Type | Usage |
|---|---|---|
| FastAPI documentation (fastapi.tiangolo.com) | Official docs | API routing, response models, middleware |
| Docker Compose file reference (docs.docker.com) | Official docs | Service configuration, health checks, volumes |
| Certbot documentation (certbot.eff.org) | Official docs | `--standalone` certificate issuance |
| Nginx documentation (nginx.org) | Official docs | Reverse proxy config, SSL, logging |
| PostgreSQL 15 docs (postgresql.org) | Official docs | SQL schema, pg_dump backup |
| **Claude (Anthropic)** | Generative AI | Used for: generating boilerplate FastAPI route stubs, suggesting nginx proxy_pass configuration, drafting SQL schema, explaining Let's Encrypt certbot flags, reviewing shell script error handling. All generated output was reviewed, tested and adapted by the author. |

44
sk1/backup.sh Normal file
View File

@ -0,0 +1,44 @@
#!/bin/bash
# scripts/backup.sh — Back up the ShortLink PostgreSQL database.
# Run from the sk1 directory: ./scripts/backup.sh
# Optionally set BACKUP_DIR in .env to change the backup location.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}/.."
# Load env
set -a
# shellcheck disable=SC1091
source .env
set +a
BACKUP_DIR="${BACKUP_DIR:-./backups}"
TIMESTAMP="$(date +"%Y%m%d_%H%M%S")"
BACKUP_FILE="${BACKUP_DIR}/shortlink_${TIMESTAMP}.sql.gz"
mkdir -p "${BACKUP_DIR}"
echo "Creating database backup..."
docker exec shortlink_db pg_dump \
-U "${DB_USER}" \
-d "${DB_NAME}" \
--no-owner \
--clean \
| gzip > "${BACKUP_FILE}"
SIZE="$(du -sh "${BACKUP_FILE}" | cut -f1)"
echo "✓ Backup saved: ${BACKUP_FILE} (${SIZE})"
# Keep only the 7 most recent backups
KEPT=7
OLDER=$(ls -t "${BACKUP_DIR}"/shortlink_*.sql.gz 2>/dev/null | tail -n +"$((KEPT + 1))")
if [ -n "$OLDER" ]; then
echo "$OLDER" | xargs rm -f
echo " Old backups removed (keeping last ${KEPT})."
fi
# ── To restore a backup ───────────────────────────────────────────────────────
# gunzip -c backups/shortlink_<timestamp>.sql.gz \
# | docker exec -i shortlink_db psql -U "$DB_USER" -d "$DB_NAME"

63
sk1/docker-compose.yml Normal file
View File

@ -0,0 +1,63 @@
version: '3.8'
services:
db:
image: postgres:15-alpine
container_name: shortlink_db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
networks:
- app_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: shortlink_backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
SECRET_KEY: ${SECRET_KEY}
BASE_URL: ${BASE_URL}
ADMIN_TOKEN: ${ADMIN_TOKEN}
depends_on:
db:
condition: service_healthy
networks:
- app_network
nginx:
image: nginx:alpine
container_name: shortlink_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/html:/usr/share/nginx/html:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- nginx_logs:/var/log/nginx
depends_on:
- backend
networks:
- app_network
networks:
app_network:
driver: bridge
volumes:
postgres_data:
nginx_logs:

312
sk1/index.html Normal file
View File

@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ShortLink — URL Shortener</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--accent: #6366f1;
--accent2: #4f46e5;
--text: #f1f5f9;
--muted: #94a3b8;
--success: #22c55e;
--error: #ef4444;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh;
}
/* ── Header ── */
header {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 1rem 2rem; display: flex; align-items: center; gap: .75rem;
}
.logo {
width: 34px; height: 34px; background: var(--accent); border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 1.1rem; color: #fff; flex-shrink: 0;
}
header h1 { font-size: 1.4rem; font-weight: 700; }
header h1 span { color: var(--accent); }
.tagline { margin-left: auto; color: var(--muted); font-size: .85rem; }
/* ── Layout ── */
main { max-width: 860px; margin: 0 auto; padding: 2rem 1rem; }
/* ── Cards ── */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem;
}
.card-title {
font-size: .75rem; font-weight: 600; letter-spacing: .08em;
color: var(--muted); text-transform: uppercase; margin-bottom: 1rem;
}
/* ── Form controls ── */
.input-row { display: flex; gap: .5rem; }
input[type="text"] {
flex: 1; background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: .7rem 1rem; color: var(--text);
font-size: 1rem; outline: none; transition: border-color .15s;
}
input[type="text"]:focus { border-color: var(--accent); }
input::placeholder { color: var(--muted); }
.btn {
background: var(--accent); color: #fff; border: none; border-radius: 8px;
padding: .7rem 1.4rem; font-size: 1rem; font-weight: 600; cursor: pointer;
transition: background .15s; white-space: nowrap;
}
.btn:hover { background: var(--accent2); }
.btn:active { transform: scale(.98); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-sm {
padding: .4rem .9rem; font-size: .85rem; font-weight: 500;
}
.btn-ghost {
background: transparent; color: var(--muted); border: 1px solid var(--border);
}
.btn-ghost:hover { background: var(--border); color: var(--text); }
.toggle-link {
display: inline-block; margin-top: .6rem; color: var(--muted);
font-size: .85rem; cursor: pointer; user-select: none;
}
.toggle-link:hover { color: var(--text); }
.advanced { margin-top: .75rem; display: none; }
.advanced label { display: block; font-size: .85rem; color: var(--muted); margin-bottom: .3rem; }
/* ── Result ── */
.result-box {
margin-top: 1rem; background: var(--bg); border-radius: 8px;
padding: .9rem 1rem; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
}
.result-box a { color: var(--accent); font-weight: 600; word-break: break-all; flex: 1; }
.result-box a:hover { text-decoration: underline; }
.msg {
margin-top: .6rem; padding: .45rem .9rem; border-radius: 6px; font-size: .88rem;
}
.msg.ok { background: rgba(34,197,94,.1); color: var(--success); }
.msg.err { background: rgba(239,68,68,.1); color: var(--error); }
/* ── Stats bar ── */
.stats-bar {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 1rem;
}
.pills { display: flex; gap: .75rem; }
.pill {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: .4rem .9rem; text-align: center; font-size: .82rem;
}
.pill strong { display: block; font-size: 1.2rem; color: var(--accent); }
/* ── Table ── */
.tbl-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
th {
text-align: left; padding: .5rem .75rem; color: var(--muted);
font-weight: 500; border-bottom: 1px solid var(--border);
}
td { padding: .7rem .75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(99,102,241,.04); }
.code-tag {
background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
padding: .15rem .45rem; font-family: monospace; font-size: .82rem;
}
.url-cell { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.visits-badge {
display: inline-block; background: var(--accent); color: #fff;
border-radius: 99px; padding: .15rem .55rem; font-size: .75rem; font-weight: 600;
}
.go-btn {
color: var(--accent); border: 1px solid var(--accent); border-radius: 4px;
padding: .2rem .55rem; font-size: .78rem; cursor: pointer; background: transparent;
transition: background .15s, color .15s;
}
.go-btn:hover { background: var(--accent); color: #fff; }
.del-btn {
color: var(--error); border: 1px solid var(--error); border-radius: 4px;
padding: .2rem .55rem; font-size: .78rem; cursor: pointer; background: transparent;
}
.del-btn:hover { background: var(--error); color: #fff; }
.empty { color: var(--muted); text-align: center; padding: 2.5rem; }
/* ── Footer ── */
footer {
text-align: center; color: var(--muted); font-size: .8rem;
padding: 2rem 1rem; border-top: 1px solid var(--border); margin-top: 1rem;
}
</style>
</head>
<body>
<header>
<div class="logo">S</div>
<h1>Short<span>Link</span></h1>
<span class="tagline">Shorten, share & track URLs</span>
</header>
<main>
<!-- ── Shorten form ── -->
<div class="card">
<p class="card-title">✂ Shorten a URL</p>
<div class="input-row">
<input type="text" id="urlInput" placeholder="https://very-long-url.example.com/path/to/page" />
<button class="btn" onclick="shorten()" id="shortenBtn">Shorten</button>
</div>
<span class="toggle-link" onclick="toggleAdvanced()">⚙ Custom code / title</span>
<div class="advanced" id="advPanel">
<div style="display:flex;gap:.75rem;flex-wrap:wrap;margin-top:.5rem">
<div style="flex:1;min-width:160px">
<label>Custom short code (optional)</label>
<input type="text" id="customCode" placeholder="my-link" />
</div>
<div style="flex:1;min-width:160px">
<label>Title / description (optional)</label>
<input type="text" id="titleInput" placeholder="My awesome page" />
</div>
</div>
</div>
<div id="resultArea"></div>
</div>
<!-- ── Analytics ── -->
<div class="card">
<div class="stats-bar">
<p class="card-title" style="margin:0">📊 Analytics</p>
<button class="btn btn-sm btn-ghost" onclick="loadStats()">↻ Refresh</button>
</div>
<div class="pills" id="pills"></div>
<div class="tbl-wrap" id="tblArea" style="margin-top:1rem"><div class="empty">Loading…</div></div>
</div>
</main>
<footer>ShortLink — Cloud Deployment Assignment · Built with FastAPI + PostgreSQL + Nginx</footer>
<script>
const API = '';
async function shorten() {
const url = document.getElementById('urlInput').value.trim();
const code = document.getElementById('customCode').value.trim();
const title = document.getElementById('titleInput').value.trim();
const out = document.getElementById('resultArea');
const btn = document.getElementById('shortenBtn');
if (!url) { out.innerHTML = '<div class="msg err">Please enter a URL.</div>'; return; }
btn.disabled = true; btn.textContent = 'Shortening…';
try {
const resp = await fetch(`${API}/api/shorten`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ url, custom_code: code || undefined, title: title || undefined })
});
const data = await resp.json();
if (!resp.ok) {
out.innerHTML = `<div class="msg err">Error: ${data.detail}</div>`;
return;
}
out.innerHTML = `
<div class="result-box">
<a href="${data.short_url}" target="_blank" rel="noopener">${data.short_url}</a>
<button class="btn btn-sm" onclick="copy('${data.short_url}', this)">Copy</button>
</div>
<div class="msg ok">✓ Short link created successfully!</div>`;
document.getElementById('urlInput').value = '';
loadStats();
} catch (e) {
out.innerHTML = `<div class="msg err">Network error: ${e.message}</div>`;
} finally {
btn.disabled = false; btn.textContent = 'Shorten';
}
}
async function copy(url, btn) {
await navigator.clipboard.writeText(url);
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 2000);
}
function toggleAdvanced() {
const p = document.getElementById('advPanel');
p.style.display = p.style.display === 'block' ? 'none' : 'block';
}
async function loadStats() {
try {
const resp = await fetch(`${API}/api/stats`);
const data = await resp.json();
document.getElementById('pills').innerHTML = `
<div class="pill"><strong>${data.total_links}</strong>Links</div>
<div class="pill"><strong>${data.total_visits}</strong>Visits</div>`;
if (!data.links.length) {
document.getElementById('tblArea').innerHTML = '<div class="empty">No links yet. Shorten your first URL above!</div>';
return;
}
const rows = data.links.map(l => `
<tr>
<td><span class="code-tag">${l.code}</span></td>
<td class="url-cell" title="${l.original_url}">${l.title || l.original_url}</td>
<td class="url-cell" title="${l.original_url}" style="color:var(--muted);font-size:.8rem">${l.original_url}</td>
<td><span class="visits-badge">${l.visit_count}</span></td>
<td style="color:var(--muted);font-size:.78rem">${new Date(l.created_at).toLocaleDateString()}</td>
<td>
<button class="go-btn" onclick="window.open('${l.short_url}','_blank')">Open</button>
<button class="del-btn" onclick="deleteLink('${l.code}')" style="margin-left:4px">Del</button>
</td>
</tr>`).join('');
document.getElementById('tblArea').innerHTML = `
<table>
<thead><tr>
<th>Code</th><th>Title</th><th>Original URL</th>
<th>Visits</th><th>Created</th><th>Actions</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
} catch (e) {
document.getElementById('tblArea').innerHTML = '<div class="empty">Could not load statistics.</div>';
}
}
async function deleteLink(code) {
const token = prompt('Enter admin token to delete:');
if (!token) return;
const resp = await fetch(`${API}/api/links/${code}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
if (resp.ok) loadStats();
else alert('Delete failed wrong token or link not found.');
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('urlInput').addEventListener('keydown', e => {
if (e.key === 'Enter') shorten();
});
loadStats();
setInterval(loadStats, 30000);
});
</script>
</body>
</html>

23
sk1/init.sql Normal file
View File

@ -0,0 +1,23 @@
-- ShortLink database schema
CREATE TABLE IF NOT EXISTS links (
id SERIAL PRIMARY KEY,
code VARCHAR(20) UNIQUE NOT NULL,
original_url TEXT NOT NULL,
title VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by_ip VARCHAR(45)
);
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY,
link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE,
visited_at TIMESTAMP NOT NULL DEFAULT NOW(),
ip_address VARCHAR(45),
user_agent TEXT,
referer TEXT
);
CREATE INDEX IF NOT EXISTS idx_links_code ON links(code);
CREATE INDEX IF NOT EXISTS idx_visits_link_id ON visits(link_id);
CREATE INDEX IF NOT EXISTS idx_visits_visited ON visits(visited_at);

190
sk1/main.py Normal file
View File

@ -0,0 +1,190 @@
from fastapi import FastAPI, HTTPException, Request, Header
from fastapi.responses import RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from datetime import datetime
import psycopg2
import psycopg2.pool
import os
import string
import random
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI(title="ShortLink API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
DATABASE_URL = os.environ["DATABASE_URL"]
BASE_URL = os.environ.get("BASE_URL", "http://localhost")
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
_pool = None
def get_pool():
global _pool
if _pool is None:
_pool = psycopg2.pool.SimpleConnectionPool(1, 10, DATABASE_URL)
return _pool
def get_client_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
return forwarded.split(",")[0].strip() if forwarded else request.client.host
def generate_code(length: int = 6) -> str:
chars = string.ascii_letters + string.digits
return "".join(random.choices(chars, k=length))
# ── Models ────────────────────────────────────────────────────────────────────
class URLCreate(BaseModel):
url: str
custom_code: str | None = None
title: str | None = None
# ── Routes ────────────────────────────────────────────────────────────────────
@app.get("/health")
def health():
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
@app.post("/api/shorten")
def shorten_url(data: URLCreate, request: Request):
url = data.url.strip()
if not url.startswith(("http://", "https://")):
url = "https://" + url
code = (data.custom_code or "").strip() or generate_code()
if not code.replace("-", "").replace("_", "").isalnum():
raise HTTPException(status_code=400, detail="Code may only contain letters, digits, hyphens and underscores")
pool = get_pool()
conn = pool.getconn()
try:
cur = conn.cursor()
cur.execute("SELECT id FROM links WHERE code = %s", (code,))
if cur.fetchone():
raise HTTPException(status_code=409, detail="That short code is already in use")
ip = get_client_ip(request)
cur.execute(
"INSERT INTO links (code, original_url, title, created_at, created_by_ip) VALUES (%s, %s, %s, %s, %s)",
(code, url, data.title, datetime.utcnow(), ip),
)
conn.commit()
cur.close()
logger.info("Created link %s -> %s", code, url)
return {"short_url": f"{BASE_URL}/s/{code}", "code": code, "original_url": url}
except HTTPException:
raise
except Exception as exc:
conn.rollback()
logger.error("Error creating link: %s", exc)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
pool.putconn(conn)
@app.get("/api/stats")
def get_stats():
pool = get_pool()
conn = pool.getconn()
try:
cur = conn.cursor()
cur.execute("""
SELECT l.code, l.original_url, l.title, l.created_at,
COUNT(v.id) AS visit_count,
MAX(v.visited_at) AS last_visit
FROM links l
LEFT JOIN visits v ON l.id = v.link_id
GROUP BY l.id
ORDER BY visit_count DESC, l.created_at DESC
LIMIT 50
""")
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) FROM links")
total_links = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM visits")
total_visits = cur.fetchone()[0]
cur.close()
return {
"total_links": total_links,
"total_visits": total_visits,
"links": [
{
"code": r[0],
"original_url": r[1],
"title": r[2],
"created_at": r[3].isoformat(),
"visit_count": r[4],
"last_visit": r[5].isoformat() if r[5] else None,
"short_url": f"{BASE_URL}/s/{r[0]}",
}
for r in rows
],
}
finally:
pool.putconn(conn)
@app.delete("/api/links/{code}")
def delete_link(code: str, authorization: str = Header(None)):
if not authorization or authorization != f"Bearer {ADMIN_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
pool = get_pool()
conn = pool.getconn()
try:
cur = conn.cursor()
cur.execute("DELETE FROM links WHERE code = %s RETURNING id", (code,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Link not found")
conn.commit()
cur.close()
logger.info("Deleted link %s", code)
return {"message": f"Link '{code}' deleted"}
finally:
pool.putconn(conn)
@app.get("/s/{code}")
def redirect_link(code: str, request: Request):
pool = get_pool()
conn = pool.getconn()
try:
cur = conn.cursor()
cur.execute("SELECT id, original_url FROM links WHERE code = %s", (code,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Short link not found")
link_id, original_url = row
cur.execute(
"INSERT INTO visits (link_id, visited_at, ip_address, user_agent, referer) VALUES (%s, %s, %s, %s, %s)",
(link_id, datetime.utcnow(),
get_client_ip(request),
request.headers.get("User-Agent", "")[:500],
request.headers.get("Referer", "")[:500]),
)
conn.commit()
cur.close()
return RedirectResponse(url=original_url, status_code=302)
finally:
pool.putconn(conn)

70
sk1/nginx.conf.template Normal file
View File

@ -0,0 +1,70 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Access log format captures real client IP via X-Forwarded-For
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
gzip on;
gzip_types text/plain text/css application/json application/javascript;
sendfile on;
# ── HTTP → HTTPS redirect ─────────────────────────────────────────────────
server {
listen 80;
server_name DOMAIN_PLACEHOLDER;
return 301 https://$host$request_uri;
}
# ── HTTPS server ──────────────────────────────────────────────────────────
server {
listen 443 ssl;
server_name DOMAIN_PLACEHOLDER;
ssl_certificate /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
access_log /var/log/nginx/access.log main;
# Static frontend (served by nginx directly)
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Backend API
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Short link redirects
location /s/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check
location /health {
proxy_pass http://backend:8000;
}
}
}

113
sk1/prepare-app.sh Normal file
View File

@ -0,0 +1,113 @@
#!/bin/bash
# prepare-app.sh — Deploy ShortLink to Google Cloud (Ubuntu 22.04 VM)
# Conditions: Run on a fresh GCP Compute Engine VM with Ubuntu 22.04.
# Port 80 and 443 must be open in firewall.
# DNS A record for DOMAIN must already point to this VM's external IP.
# The .env file must exist (copy from .env.example and fill in values).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "======================================================"
echo " ShortLink — Cloud Deployment Script"
echo "======================================================"
echo ""
# ── 1. Load and validate environment ─────────────────────────────────────────
if [ ! -f ".env" ]; then
echo "ERROR: .env file not found!"
echo " Run: cp .env.example .env"
echo " Then edit .env with your values."
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
: "${DOMAIN:?Set DOMAIN in .env (e.g. shortlink.example.com)}"
: "${EMAIL:?Set EMAIL in .env (used for SSL certificate)}"
: "${DB_NAME:?Set DB_NAME in .env}"
: "${DB_USER:?Set DB_USER in .env}"
: "${DB_PASSWORD:?Set DB_PASSWORD in .env}"
: "${BASE_URL:?Set BASE_URL in .env (e.g. https://shortlink.example.com)}"
echo "[ENV] Domain: $DOMAIN"
echo "[ENV] Base URL: $BASE_URL"
echo ""
# ── 2. Install Docker ─────────────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then
echo "[1/5] Installing Docker..."
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
# Re-exec with docker group applied so the rest of the script can use docker
exec sg docker "$0" "$@"
else
echo "[1/5] Docker already installed: $(docker --version)"
fi
# ── 3. Install Docker Compose plugin ─────────────────────────────────────────
if ! docker compose version &>/dev/null 2>&1; then
echo "[2/5] Installing Docker Compose plugin..."
sudo apt-get update -qq
sudo apt-get install -y docker-compose-plugin
else
echo "[2/5] Docker Compose installed: $(docker compose version)"
fi
# ── 4. Install Certbot ────────────────────────────────────────────────────────
if ! command -v certbot &>/dev/null; then
echo "[3/5] Installing Certbot..."
sudo apt-get update -qq
sudo apt-get install -y certbot
else
echo "[3/5] Certbot already installed: $(certbot --version)"
fi
# ── 5. Obtain SSL certificate ─────────────────────────────────────────────────
if [ ! -d "/etc/letsencrypt/live/${DOMAIN}" ]; then
echo "[4/5] Obtaining SSL certificate for ${DOMAIN}..."
echo " (Certbot will temporarily listen on port 80 — ensure it is open)"
sudo certbot certonly \
--standalone \
--non-interactive \
--agree-tos \
--email "${EMAIL}" \
-d "${DOMAIN}"
echo " Certificate obtained successfully."
else
echo "[4/5] SSL certificate already exists for ${DOMAIN}."
fi
# ── 6. Generate nginx.conf from template ──────────────────────────────────────
echo "[5/5] Generating nginx configuration..."
sed "s/DOMAIN_PLACEHOLDER/${DOMAIN}/g" nginx/nginx.conf.template > nginx/nginx.conf
# ── 7. Build and start containers ────────────────────────────────────────────
echo "[5/5] Building images and starting containers..."
docker compose up -d --build
# ── 8. Configure automatic SSL renewal ───────────────────────────────────────
APP_DIR="$SCRIPT_DIR"
RENEW_CMD="certbot renew --quiet --pre-hook 'docker stop shortlink_nginx' --post-hook 'docker start shortlink_nginx' >> /var/log/certbot-renew.log 2>&1"
(sudo crontab -l 2>/dev/null | grep -v certbot; echo "0 3 * * 0 $RENEW_CMD") | sudo crontab -
echo " SSL auto-renewal cron job configured (every Sunday 03:00)."
echo ""
echo "╔══════════════════════════════════════════════╗"
echo "║ ✅ Deployment Successful! ║"
echo "╚══════════════════════════════════════════════╝"
echo ""
echo " Application URL : https://${DOMAIN}"
echo " Health endpoint : https://${DOMAIN}/health"
echo ""
echo "Container status:"
docker compose ps
echo ""
echo "To view logs: docker compose logs -f"
echo "To stop: ./remove-app.sh"
echo "To backup: ./scripts/backup.sh"

38
sk1/remove-app.sh Normal file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# remove-app.sh — Stop and remove all ShortLink containers, networks and volumes.
# Conditions: Run from the sk1 directory on the VM where prepare-app.sh was executed.
# Docker and Docker Compose must be installed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "======================================================"
echo " ShortLink — Removal Script"
echo "======================================================"
echo ""
echo "This will:"
echo " • Stop all ShortLink containers"
echo " • Remove containers, networks, and named volumes (database data)"
echo " • SSL certificates will NOT be removed"
echo ""
read -rp "Are you sure? [y/N] " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
echo ""
echo "[1/2] Stopping and removing containers, networks and volumes..."
docker compose down -v --remove-orphans
echo "[2/2] Removing built images..."
docker compose images -q 2>/dev/null | xargs -r docker rmi -f 2>/dev/null || true
echo ""
echo "✅ Application removed successfully."
echo ""
echo "Note: SSL certificates in /etc/letsencrypt were NOT removed."
echo "To remove them manually:"
echo " sudo certbot delete --cert-name ${DOMAIN:-your-domain}"

4
sk1/requirements.txt Normal file
View File

@ -0,0 +1,4 @@
fastapi==0.111.0
uvicorn[standard]==0.30.0
psycopg2-binary==2.9.9
pydantic==2.7.1