Upload files to "sk1"
This commit is contained in:
parent
59bd43b8ca
commit
88413f8d95
16
sk1/.env.example
Normal file
16
sk1/.env.example
Normal 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
8
sk1/.gitignore
vendored
Normal 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
15
sk1/Dockerfile
Normal 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
233
sk1/README.md
Normal 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
44
sk1/backup.sh
Normal 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
63
sk1/docker-compose.yml
Normal 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
312
sk1/index.html
Normal 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
23
sk1/init.sql
Normal 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
190
sk1/main.py
Normal 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
70
sk1/nginx.conf.template
Normal 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
113
sk1/prepare-app.sh
Normal 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
38
sk1/remove-app.sh
Normal 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
4
sk1/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.30.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.7.1
|
||||||
Loading…
Reference in New Issue
Block a user