update cdn
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import secrets
|
||||
import datetime
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from flask import *
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_PATH = os.path.join(APP_DIR, "data.db")
|
||||
UPLOAD_DIR = os.path.join(APP_DIR, "uploads")
|
||||
FLAG_PATH = os.path.join("/flag.txt")
|
||||
|
||||
ALLOWED_EXT = {"png", "jpg", "jpeg", "bmp"}
|
||||
MAX_CONTENT_LENGTH = 8 * 1024 * 1024
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(16))
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
||||
app.config["UPLOAD_FOLDER"] = UPLOAD_DIR
|
||||
|
||||
def get_db():
|
||||
db = getattr(g, "_db", None)
|
||||
if db is None:
|
||||
db = g._db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
db.row_factory = sqlite3.Row
|
||||
return db
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(_exc):
|
||||
db = getattr(g, "_db", None)
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
def init_db():
|
||||
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||
db = get_db()
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
def _resolve_flag_file_path():
|
||||
p = FLAG_PATH
|
||||
if os.path.isdir(p):
|
||||
p = os.path.join(p, "flag.txt")
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
return p
|
||||
|
||||
def generate_flag_at_boot():
|
||||
path = _resolve_flag_file_path()
|
||||
if not os.path.exists(path) or os.environ.get("RESEED_FLAG") == "1":
|
||||
token = secrets.token_bytes(32)
|
||||
sha = hashlib.sha256(token).hexdigest()
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(f"GEMASTIK{{{sha}}}\n")
|
||||
|
||||
def current_user():
|
||||
if "uid" not in session:
|
||||
return None
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT id, username, role FROM users WHERE id = ?", (session["uid"],))
|
||||
return cur.fetchone()
|
||||
|
||||
def _is_within(child_path: str, parent_dir: str) -> bool:
|
||||
child_real = os.path.realpath(child_path)
|
||||
parent_real = os.path.realpath(parent_dir)
|
||||
try:
|
||||
return os.path.commonpath([child_real, parent_real]) == parent_real
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _exiftool_text(path_on_disk: str) -> str:
|
||||
if not _is_within(path_on_disk, UPLOAD_DIR):
|
||||
return "no-metadata"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["exiftool", "--", path_on_disk],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return "no-metadata"
|
||||
return proc.stdout if proc.stdout else "no-metadata"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "exif_err: timeout"
|
||||
except FileNotFoundError:
|
||||
return "exif_err: exiftool not found"
|
||||
except Exception as e:
|
||||
return f"exif_err: {e}"
|
||||
|
||||
def allowed_file(fn: str) -> bool:
|
||||
if "." not in fn:
|
||||
return False
|
||||
ext = fn.rsplit(".", 1)[-1].lower()
|
||||
return ext in ALLOWED_EXT
|
||||
|
||||
def sha256_hex(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
if not username or not password:
|
||||
flash("Username and password required")
|
||||
return render_template("register.html")
|
||||
pw_hash = generate_password_hash(password)
|
||||
try:
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, 'user', ?)",
|
||||
(username, pw_hash, datetime.datetime.utcnow().isoformat() + "Z"),
|
||||
)
|
||||
db.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Username already exists")
|
||||
return render_template("register.html")
|
||||
flash("Registered. Please login.")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("register.html")
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"SELECT id, username, password_hash, role FROM users WHERE username = ?",
|
||||
(username,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row or not check_password_hash(row["password_hash"], password):
|
||||
flash("Invalid credentials")
|
||||
return render_template("login.html")
|
||||
session["uid"] = row["id"]
|
||||
flash(f"Welcome, {row['username']}!")
|
||||
return redirect(url_for("gallery"))
|
||||
return render_template("login.html")
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
flash("Logged out")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
@app.route("/upload", methods=["GET", "POST"])
|
||||
def upload():
|
||||
user = current_user()
|
||||
if not user:
|
||||
return redirect(url_for("login"))
|
||||
if request.method == "POST":
|
||||
title = request.form.get("title", "").strip() or "(untitled)"
|
||||
f = request.files.get("image")
|
||||
if not f or f.filename == "":
|
||||
flash("Choose an image.")
|
||||
return render_template("upload.html")
|
||||
orig_name = secure_filename(f.filename)
|
||||
if not allowed_file(orig_name):
|
||||
flash("Unsupported file type.")
|
||||
return render_template("upload.html")
|
||||
ext = orig_name.rsplit(".", 1)[-1].lower()
|
||||
data = f.read()
|
||||
sha = sha256_hex(data)
|
||||
stored = f"{sha}.{ext}"
|
||||
path = Path(UPLOAD_DIR) / stored
|
||||
if not path.exists():
|
||||
path.write_bytes(data)
|
||||
metadata_blob = _exiftool_text(str(path))
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO posts (user_id, title, filename, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user["id"], title, stored, metadata_blob, datetime.datetime.utcnow().isoformat() + "Z"),
|
||||
)
|
||||
db.commit()
|
||||
flash("Upload complete.")
|
||||
return redirect(url_for("gallery"))
|
||||
return render_template("upload.html")
|
||||
|
||||
@app.route("/")
|
||||
@app.route("/gallery")
|
||||
def gallery():
|
||||
user = current_user()
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"""
|
||||
SELECT p.id, p.title, p.filename, p.created_at, u.username
|
||||
FROM posts p JOIN users u ON p.user_id = u.id
|
||||
ORDER BY p.id DESC
|
||||
"""
|
||||
)
|
||||
posts = cur.fetchall()
|
||||
return render_template("gallery.html", posts=posts, user=user)
|
||||
|
||||
@app.route("/post/<int:pid>")
|
||||
def view_post(pid):
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"SELECT p.*, u.username FROM posts p JOIN users u ON p.user_id = u.id WHERE p.id = ?",
|
||||
(pid,),
|
||||
)
|
||||
post = cur.fetchone()
|
||||
if not post:
|
||||
abort(404)
|
||||
if request.args.get("meta") == "1":
|
||||
return Response((post["metadata"] or ""), mimetype="text/plain")
|
||||
metadata_full = post["metadata"] or ""
|
||||
md_map = {"File Name": "", "Date Created": ""}
|
||||
for m in re.finditer(r"^\s*(File Name|Date Created)\s*:\s*(.*)$", metadata_full, flags=re.MULTILINE):
|
||||
key = m.group(1)
|
||||
val = m.group(2).strip()
|
||||
md_map[key] = val
|
||||
file_name_val = md_map["File Name"]
|
||||
date_created_val = md_map["Date Created"]
|
||||
metadata_snippet_html = f"<pre>File Name: {file_name_val}\nDate Created: {date_created_val}</pre>"
|
||||
tpl_path = os.path.join(APP_DIR, "templates", "view_post.html")
|
||||
with open(tpl_path, "r", encoding="utf-8") as fh:
|
||||
tpl_src = fh.read()
|
||||
placeholder = "{{ metadata_snippet or '' }}"
|
||||
if placeholder not in tpl_src:
|
||||
page_src = tpl_src + "\n" + metadata_snippet_html
|
||||
else:
|
||||
page_src = tpl_src.replace(placeholder, metadata_snippet_html)
|
||||
return render_template_string(page_src, post=post)
|
||||
|
||||
@app.route("/i/<path:filename>")
|
||||
def cdn_serve(filename):
|
||||
return send_from_directory(UPLOAD_DIR, filename, as_attachment=False)
|
||||
|
||||
@app.errorhandler(413)
|
||||
def too_large(_):
|
||||
flash("File too large.")
|
||||
return redirect(url_for("upload"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
init_db()
|
||||
app.run(host="0.0.0.0", port=8000, debug=False)
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
umask 002 # group-writable (helps with bind mounts)
|
||||
|
||||
APP_DIR="/app"
|
||||
UPLOADS_DIR="${APP_DIR}/uploads"
|
||||
DATA_DIR="/data"
|
||||
DBFILE="${DATA_DIR}/app.db"
|
||||
FLAG_FILE="/flag.txt"
|
||||
|
||||
mkdir -p "${UPLOADS_DIR}" "${DATA_DIR}"
|
||||
|
||||
# Make sure runtime dirs are writable (bind-mounts may ignore chown; that's OK)
|
||||
chown -R ctfuser:ctfuser "${UPLOADS_DIR}" "${DATA_DIR}" 2>/dev/null || true
|
||||
chmod 775 "${UPLOADS_DIR}" "${DATA_DIR}" || true
|
||||
|
||||
# ------- Start SSH (and set password if provided) -------
|
||||
if [[ -n "${SSH_PASSWORD:-}" ]]; then
|
||||
echo "ctfuser:${SSH_PASSWORD}" | chpasswd || true
|
||||
fi
|
||||
|
||||
if command -v service >/dev/null 2>&1; then
|
||||
service ssh start || /usr/sbin/sshd &
|
||||
else
|
||||
/usr/sbin/sshd &
|
||||
fi
|
||||
# -------------------------------------------------------
|
||||
|
||||
# Initialize DB AS ctfuser (so SQLite can write WAL/SHM next to it)
|
||||
su -s /bin/bash -c "python - <<'PY'
|
||||
import app as m
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def ctx():
|
||||
with m.app.app_context():
|
||||
yield
|
||||
|
||||
with ctx():
|
||||
# your function should create tables if missing
|
||||
m.init_db()
|
||||
# OPTIONAL: if you had a generate_flag_at_boot, call it here as well
|
||||
try:
|
||||
m.generate_flag_at_boot()
|
||||
except Exception:
|
||||
pass
|
||||
print('DB initialized by ctfuser.')
|
||||
PY
|
||||
" ctfuser
|
||||
|
||||
# OPTIONAL: if your filesystem dislikes WAL, uncomment:
|
||||
# su -s /bin/bash -c \"python - <<'PY'
|
||||
# import sqlite3
|
||||
# import os
|
||||
# db = sqlite3.connect(os.environ.get('DB_PATH', '${DBFILE}'))
|
||||
# db.execute('PRAGMA journal_mode=DELETE;')
|
||||
# db.execute('PRAGMA synchronous=NORMAL;')
|
||||
# db.close()
|
||||
# print('SQLite journal_mode=DELETE applied.')
|
||||
# PY
|
||||
# \" ctfuser
|
||||
|
||||
# Secure the flag file if present
|
||||
if [ -d "${FLAG_FILE}" ]; then
|
||||
if [ -f "${FLAG_FILE}" ]; then
|
||||
chown root:root "${FLAG_FILE}" || true
|
||||
chmod 444 "${FLAG_FILE}" || true
|
||||
fi
|
||||
else
|
||||
if [ -f "${FLAG_FILE}" ]; then
|
||||
chown root:root "${FLAG_FILE}" || true
|
||||
chmod 444 "${FLAG_FILE}" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Starting Flask app on 0.0.0.0:8000 as ctfuser…"
|
||||
exec su -s /bin/bash -c "cd '${APP_DIR}' && python app.py" ctfuser
|
||||
@@ -0,0 +1,4 @@
|
||||
Flask==3.0.3
|
||||
Werkzeug==3.0.3
|
||||
Pillow==10.4.0
|
||||
exifread==3.0.0
|
||||
@@ -0,0 +1,20 @@
|
||||
:root { --bg: #0b0d10; --fg: #e5e7eb; --muted:#9ca3af; --card:#111317; --accent:#60a5fa; --stroke:#1f2937; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background: var(--bg); color: var(--fg); }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
.topbar { display:flex; justify-content:space-between; align-items:center; padding:12px 16px; border-bottom:1px solid var(--stroke); background:#0e1116; }
|
||||
.brand { font-weight:700; letter-spacing:.3px; }
|
||||
.container { max-width: 980px; margin: 24px auto; padding: 0 16px; }
|
||||
.flash > div { background:#1a2332; border:1px solid #22314a; padding:8px 12px; margin:12px 0; border-radius:8px; }
|
||||
label { display:block; margin:12px 0 6px; color: var(--muted); }
|
||||
input, textarea { width:100%; padding:10px 12px; border-radius:8px; border:1px solid var(--stroke); background:#0f1217; color:var(--fg); }
|
||||
button { margin-top:12px; padding:10px 16px; border-radius:8px; border:1px solid #2b3344; background:#1b2333; color:#dbeafe; cursor:pointer; }
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap:16px; }
|
||||
.card { display:block; border:1px solid var(--stroke); border-radius:12px; overflow:hidden; background: var(--card); }
|
||||
.card img { width:100%; height:160px; object-fit:cover; display:block; }
|
||||
.card .meta { padding:10px 12px; }
|
||||
.card .title { font-weight:600; }
|
||||
.card .sub { color: var(--muted); font-size: 12px; margin-top:4px; }
|
||||
.post .full { width:100%; max-height:65vh; object-fit:contain; border:1px solid var(--stroke); border-radius:12px; }
|
||||
details.desc { margin-top:12px; }
|
||||
.foot { border-top:1px solid var(--stroke); color:var(--muted); padding:16px; text-align:center; margin-top:40px; }
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>pix.cdn — demo</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a href="{{ url_for('gallery') }}" class="brand">pix.cdn</a>
|
||||
<nav>
|
||||
{% if session.get('uid') %}
|
||||
<a href="{{ url_for('upload') }}">Upload</a>
|
||||
<a href="{{ url_for('logout') }}">Logout</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}">Login</a>
|
||||
<a href="{{ url_for('register') }}">Register</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% with msgs = get_flashed_messages() %}
|
||||
{% if msgs %}
|
||||
<div class="flash">
|
||||
{% for m in msgs %}<div>{{ m }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="foot">© pix.cdn</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Gallery</h2>
|
||||
|
||||
{% if not posts %}
|
||||
<p>No posts yet. <a href="{{ url_for('upload') }}">Upload one</a>.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid">
|
||||
{% for p in posts %}
|
||||
<a class="card" href="{{ url_for('view_post', pid=p.id) }}">
|
||||
<img src="{{ url_for('cdn_serve', filename=p.filename) }}" alt="thumb" />
|
||||
<div class="meta">
|
||||
<div class="title">{{ p.title }}</div>
|
||||
<div class="sub">{{ p.username }} · {{ p.created_at }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Login</h2>
|
||||
<form method="post">
|
||||
<label>Username</label>
|
||||
<input name="username" required />
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" required />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Register</h2>
|
||||
<form method="post">
|
||||
<label>Username</label>
|
||||
<input name="username" required />
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" required />
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
<p class="hint">New users default to role <code>user</code>.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Upload image</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<label>Title</label>
|
||||
<input name="title" placeholder="My picture" />
|
||||
<label>Image file (png/jpg/jpeg/bmp)</label>
|
||||
<input type="file" name="image" accept=".png,.jpg,.jpeg,.bmp" required />
|
||||
<label>Notes (optional, saved with metadata)</label>
|
||||
<textarea name="notes" rows="4" placeholder="CDN descriptors, tags, etc."></textarea>
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<article class="post">
|
||||
<h2>{{ post.title }}</h2>
|
||||
<img class="full" src="{{ url_for('cdn_serve', filename=post.filename) }}" alt="" />
|
||||
|
||||
<details class="desc">
|
||||
<summary>Delivery details</summary>
|
||||
<p>Static CDN reference: <code>/i/{{ post.filename }}</code></p>
|
||||
<p>Uploaded at: <code>{{ post.created_at }}</code></p>
|
||||
</details>
|
||||
<template id="internal-meta" hidden>{{ metadata_snippet or '' }}</template>
|
||||
</article>
|
||||
{% endblock %}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
Reference in New Issue
Block a user