add blogpost and cdn chall
This commit is contained in:
1 parent
ea02892f14
commit
2d51b73a1e
47 files changed
+1904
No files matched your search
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libimage-exiftool-perl \
|
||||
sqlite3 \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY . /app
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
RUN mkdir -p /data /app/uploads
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,248 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from flask import *
|
||||
from werkzeug.utils import *
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import hashlib
|
||||
import re
|
||||
from markupsafe import escape as m_escape
|
||||
|
||||
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_FOLDER = os.path.join(APP_DIR, "uploads")
|
||||
DB_PATH = "/data/app.db"
|
||||
FLAG_PATH = "/app/flag.txt"
|
||||
|
||||
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'bmp'}
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24)
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024
|
||||
|
||||
def get_db():
|
||||
db = getattr(g, "_database", None)
|
||||
if db is None:
|
||||
db = g._database = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
db.row_factory = sqlite3.Row
|
||||
return db
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_connection(exception):
|
||||
db = getattr(g, "_database", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "").strip()
|
||||
if not username or not password:
|
||||
flash("Missing username or password")
|
||||
return redirect(url_for("register"))
|
||||
hashed = generate_password_hash(password)
|
||||
db = get_db()
|
||||
try:
|
||||
db.execute("INSERT INTO users (username, password, role) VALUES (?, ?, ?)", (username, hashed, "user"))
|
||||
db.commit()
|
||||
flash("Registered. Please login.")
|
||||
return redirect(url_for("login"))
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Username already taken")
|
||||
return redirect(url_for("register"))
|
||||
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", "").strip()
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT id, username, password, role FROM users WHERE username = ?", (username,))
|
||||
row = cur.fetchone()
|
||||
if row and check_password_hash(row["password"], password):
|
||||
session["user_id"] = row["id"]
|
||||
session["username"] = row["username"]
|
||||
session["role"] = row["role"]
|
||||
flash("Logged in")
|
||||
return redirect(url_for("index"))
|
||||
else:
|
||||
flash("Invalid credentials")
|
||||
return render_template("login.html")
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
flash("Logged out")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def index():
|
||||
if "user_id" not in session:
|
||||
return redirect(url_for("login"))
|
||||
|
||||
user_id = session["user_id"]
|
||||
db = get_db()
|
||||
q = request.values.get("q", "").strip()
|
||||
|
||||
if q:
|
||||
cur = db.execute(
|
||||
"SELECT p.*, u.username AS author "
|
||||
"FROM posts p LEFT JOIN users u ON p.author_id = u.id "
|
||||
"WHERE p.author_id = ? AND (p.title LIKE ? OR p.content LIKE ?) "
|
||||
"ORDER BY p.id DESC",
|
||||
(user_id, f"%{q}%", f"%{q}%")
|
||||
)
|
||||
else:
|
||||
cur = db.execute(
|
||||
"SELECT p.*, u.username AS author "
|
||||
"FROM posts p LEFT JOIN users u ON p.author_id = u.id "
|
||||
"WHERE p.author_id = ? "
|
||||
"ORDER BY p.id DESC",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
posts = cur.fetchall()
|
||||
return render_template("index.html", posts=posts, q=q)
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT
|
||||
|
||||
@app.route("/create", methods=["GET", "POST"])
|
||||
def create_post():
|
||||
if "user_id" not in session:
|
||||
flash("Login required")
|
||||
return redirect(url_for("login"))
|
||||
if request.method == "POST":
|
||||
title = request.form.get("title", "")
|
||||
content = request.form.get("content", "")
|
||||
file = request.files.get("image")
|
||||
image_filename = None
|
||||
metadata_text = ""
|
||||
if file and allowed_file(file.filename):
|
||||
original_filename = file.filename
|
||||
save_path = os.path.join(app.config['UPLOAD_FOLDER'], original_filename)
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
file.save(save_path)
|
||||
|
||||
try:
|
||||
cmd = f"exiftool {save_path}"
|
||||
meta_file = save_path + ".meta"
|
||||
full_cmd = f"{cmd} > {meta_file} 2>&1"
|
||||
os_status = os.system(full_cmd)
|
||||
if os.path.exists(meta_file):
|
||||
with open(meta_file, "r", encoding="utf-8", errors="ignore") as mf:
|
||||
metadata_text = mf.read()
|
||||
else:
|
||||
metadata_text = "no-metadata"
|
||||
except Exception as e:
|
||||
metadata_text = f"exif_err: {e}"
|
||||
|
||||
try:
|
||||
h = hashlib.sha256()
|
||||
with open(save_path, "rb") as fbin:
|
||||
for chunk in iter(lambda: fbin.read(8192), b""):
|
||||
h.update(chunk)
|
||||
digest = h.hexdigest()
|
||||
_, ext = os.path.splitext(original_filename)
|
||||
ext = ext.lower() if ext else ""
|
||||
new_filename = f"{digest}{ext}"
|
||||
new_path = os.path.join(app.config['UPLOAD_FOLDER'], new_filename)
|
||||
new_meta = new_path + ".meta"
|
||||
|
||||
if not os.path.exists(new_path):
|
||||
os.replace(save_path, new_path)
|
||||
else:
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists(meta_file):
|
||||
try:
|
||||
os.replace(meta_file, new_meta)
|
||||
except Exception:
|
||||
try:
|
||||
with open(meta_file, "rb") as mf_src, open(new_meta, "wb") as mf_dst:
|
||||
mf_dst.write(mf_src.read())
|
||||
os.remove(meta_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
image_filename = new_filename
|
||||
if os.path.exists(new_meta):
|
||||
try:
|
||||
with open(new_meta, "r", encoding="utf-8", errors="ignore") as mf2:
|
||||
metadata_text = mf2.read()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
image_filename = original_filename
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
cur = db.execute(
|
||||
"INSERT INTO posts (title, content, image_filename, author_id) VALUES (?, ?, ?, ?)",
|
||||
(title, content, image_filename, session['user_id'])
|
||||
)
|
||||
db.commit()
|
||||
post_id = cur.lastrowid
|
||||
metadata_insert = f"UPDATE posts SET metadata = '{metadata_text}' WHERE id = {post_id};"
|
||||
db.executescript(metadata_insert)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.execute(
|
||||
"UPDATE posts SET metadata = ? WHERE id = ?",
|
||||
(metadata_text, post_id if 'post_id' in locals() else None)
|
||||
)
|
||||
db.commit()
|
||||
flash("Post created")
|
||||
return redirect(url_for("index"))
|
||||
else:
|
||||
flash("Missing or invalid image (png/jpg/jpeg/bmp)")
|
||||
return render_template("create_post.html")
|
||||
|
||||
@app.route('/uploads/<path:filename>')
|
||||
def uploaded_file(filename):
|
||||
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
||||
|
||||
@app.route("/post/<int:pid>")
|
||||
def view_post(pid):
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"SELECT p.*, u.username as author FROM posts p LEFT JOIN users u ON p.author_id = u.id WHERE p.id = ?",
|
||||
(pid,)
|
||||
)
|
||||
post = cur.fetchone()
|
||||
if not post:
|
||||
abort(404)
|
||||
|
||||
return render_template("view_post.html", post=post)
|
||||
|
||||
@app.route("/profile")
|
||||
def profile():
|
||||
if "user_id" not in session:
|
||||
flash("Login required")
|
||||
return redirect(url_for("login"))
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT id, username, role FROM users WHERE id = ?", (session["user_id"],))
|
||||
user = cur.fetchone()
|
||||
flag_content = None
|
||||
|
||||
with open(os.path.join(APP_DIR, "templates", "profile.html"), "r", encoding="utf-8") as fh:
|
||||
profile_template = fh.read()
|
||||
|
||||
username = user["username"] if user else ""
|
||||
profile_source = profile_template.replace("{{ user.username }}", username)
|
||||
|
||||
if user and user["role"] == "admin":
|
||||
try:
|
||||
with open(FLAG_PATH, "r") as f:
|
||||
flag_content = f.read().strip()
|
||||
except Exception:
|
||||
flag_content = "flag not found"
|
||||
return render_template_string(profile_source, user=user, flag=flag_content)
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
GEMASTIK{e4666237005e8f8699f0c02ca95b8e4fa5064c93d91c67070649c6e279c13670}
|
||||
@@ -0,0 +1,10 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: ctf_web
|
||||
ports:
|
||||
- "4413:8000"
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
command: ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
FLAG_SHA=$(head -c 64 /dev/urandom | sha256sum | awk '{print $1}')
|
||||
FLAG="GEMASTIK{${FLAG_SHA}}"
|
||||
echo "$FLAG" > /app/flag.txt
|
||||
chmod 400 /app/flag.txt
|
||||
|
||||
mkdir -p /app/uploads
|
||||
mkdir -p /data
|
||||
|
||||
DBFILE=/data/app.db
|
||||
if [ ! -f "$DBFILE" ]; then
|
||||
echo "Initializing database..."
|
||||
sqlite3 $DBFILE < /app/init_db.sql
|
||||
fi
|
||||
|
||||
echo "Starting Flask app (port 8000)..."
|
||||
export FLASK_APP=/app/app.py
|
||||
export FLASK_ENV=production
|
||||
|
||||
python /app/app.py
|
||||
@@ -0,0 +1,18 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user'
|
||||
);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
image_filename TEXT,
|
||||
metadata TEXT,
|
||||
author_id INTEGER,
|
||||
FOREIGN KEY(author_id) REFERENCES users(id)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask==2.2.5
|
||||
werkzeug==2.2.3
|
||||
Jinja2==3.1.2
|
||||
@@ -0,0 +1,160 @@
|
||||
/* cool dark glass UI for the CTF blog */
|
||||
/* Variables */
|
||||
:root{
|
||||
--bg-900: #0b0e12;
|
||||
--bg-800: #0f1720;
|
||||
--panel: rgba(255,255,255,0.04);
|
||||
--glass: rgba(255,255,255,0.04);
|
||||
--muted: rgba(255,255,255,0.6);
|
||||
--accent-1: #6EE7B7; /* mint */
|
||||
--accent-2: #7C4DFF; /* violet */
|
||||
--danger: #FF6B6B;
|
||||
--radius-lg: 14px;
|
||||
--radius-md: 10px;
|
||||
--shadow-1: 0 6px 20px rgba(2,6,23,0.6);
|
||||
--card-border: linear-gradient(120deg, rgba(124,77,255,0.18), rgba(110,231,183,0.12));
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||
background: radial-gradient(1200px 600px at 10% 10%, rgba(124,77,255,0.06), transparent),
|
||||
radial-gradient(900px 400px at 90% 90%, rgba(110,231,183,0.03), transparent),
|
||||
linear-gradient(180deg,var(--bg-900),var(--bg-800));
|
||||
color: #e6eef6;
|
||||
margin:0;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
-moz-osx-font-smoothing:grayscale;
|
||||
padding:28px;
|
||||
line-height:1.45;
|
||||
}
|
||||
|
||||
header{
|
||||
display:flex;
|
||||
gap:18px;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
max-width:1100px;
|
||||
margin:0 auto 22px;
|
||||
padding:14px 18px;
|
||||
border-radius:var(--radius-lg);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
|
||||
box-shadow: var(--shadow-1);
|
||||
border: 1px solid rgba(255,255,255,0.03);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
}
|
||||
header h1{
|
||||
margin:0;
|
||||
font-size:20px;
|
||||
letter-spacing:0.4px;
|
||||
display:flex;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
}
|
||||
.logo-dot{
|
||||
width:12px;height:12px;border-radius:50%;
|
||||
background: conic-gradient(from 180deg at 50% 50%, var(--accent-1), var(--accent-2));
|
||||
box-shadow:0 4px 18px rgba(124,77,255,0.18), inset 0 -2px 6px rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
/* nav */
|
||||
nav a{
|
||||
color:var(--muted);
|
||||
text-decoration:none;
|
||||
padding:8px 12px;
|
||||
border-radius:10px;
|
||||
font-size:14px;
|
||||
}
|
||||
nav a:hover{ color: white; background: rgba(255,255,255,0.03) }
|
||||
nav a.active{
|
||||
background: linear-gradient(90deg, rgba(124,77,255,0.12), rgba(110,231,183,0.08));
|
||||
color: white;
|
||||
box-shadow: 0 6px 18px rgba(2,6,23,0.5);
|
||||
}
|
||||
|
||||
main{
|
||||
max-width:1100px;
|
||||
margin: 18px auto;
|
||||
display:grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap:18px;
|
||||
}
|
||||
|
||||
form, article, .card{
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
|
||||
border-radius: var(--radius-md);
|
||||
padding:16px;
|
||||
border: 1px solid rgba(255,255,255,0.03);
|
||||
box-shadow: 0 8px 30px rgba(2,6,23,0.45);
|
||||
}
|
||||
|
||||
input[type="text"], input[type="password"], textarea, input[type="file"], select {
|
||||
width:100%;
|
||||
padding:10px 12px;
|
||||
border-radius:8px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border:1px solid rgba(255,255,255,0.04);
|
||||
color: #e6eef6;
|
||||
outline:none;
|
||||
font-size:14px;
|
||||
margin-top:6px;
|
||||
}
|
||||
textarea{ min-height:120px; resize:vertical; }
|
||||
|
||||
button, .btn {
|
||||
display:inline-block;
|
||||
padding:10px 14px;
|
||||
border-radius:10px;
|
||||
border: none;
|
||||
cursor:pointer;
|
||||
font-weight:600;
|
||||
background: linear-gradient(90deg, var(--accent-1), var(--accent-2));
|
||||
color: #04111a;
|
||||
transition: transform .12s ease, box-shadow .12s ease, opacity .12s;
|
||||
box-shadow: 0 8px 20px rgba(124,77,255,0.12);
|
||||
}
|
||||
button:hover, .btn:hover{ transform: translateY(-2px); box-shadow: 0 14px 32px rgba(124,77,255,0.14) }
|
||||
button.ghost{
|
||||
background: transparent; color: var(--muted); border:1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
article h3{ margin:0 0 6px; font-size:18px }
|
||||
article p { color: var(--muted); margin:6px 0; }
|
||||
article img{ border-radius:8px; max-width:100%; display:block; margin:10px 0; border:1px solid rgba(255,255,255,0.03) }
|
||||
|
||||
pre{
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.012), rgba(255,255,255,0.01));
|
||||
border-radius:8px; padding:12px; overflow:auto; color:#cfeff1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace;
|
||||
font-size:13px; border:1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
ul{ list-style:none; padding:0; margin:0 0 10px 0; display:flex; gap:8px; flex-wrap:wrap }
|
||||
ul li{
|
||||
background: linear-gradient(90deg, rgba(124,77,255,0.12), rgba(110,231,183,0.07));
|
||||
padding:8px 10px; border-radius:10px; color:#eafbf6; font-weight:600;
|
||||
}
|
||||
|
||||
footer{ max-width:1100px; margin:18px auto; color:var(--muted); font-size:13px; text-align:center }
|
||||
|
||||
@media (min-width:900px){
|
||||
main{ grid-template-columns: 1fr 360px; align-items:start; }
|
||||
}
|
||||
|
||||
.label-muted{ color:var(--muted); font-size:13px }
|
||||
.badge{
|
||||
display:inline-block; padding:6px 10px; border-radius:999px; font-weight:700; font-size:12px;
|
||||
background: linear-gradient(90deg, rgba(124,77,255,0.12), rgba(110,231,183,0.06)); color:#e6fef0;
|
||||
}
|
||||
|
||||
.file-wrap{
|
||||
display:flex; gap:12px; align-items:center;
|
||||
}
|
||||
.file-wrap input[type=file]{ display:none; }
|
||||
.file-btn{
|
||||
display:inline-flex; align-items:center; gap:8px; padding:8px 12px; border-radius:8px;
|
||||
background: rgba(255,255,255,0.02); border:1px dashed rgba(255,255,255,0.04); color:var(--muted);
|
||||
}
|
||||
|
||||
:focus{ outline: 3px solid rgba(124,77,255,0.12); outline-offset:3px }
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
|
||||
<div class="card" style="padding:20px; max-width:900px; margin:auto;">
|
||||
<h2 style="margin-top:0">Create post (upload image)</h2>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="card" style="padding:16px; gap:12px; display:flex; flex-direction:column;">
|
||||
<label>
|
||||
<div class="label-muted">Title</div>
|
||||
<input name="title" placeholder="Post title" type="text">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<div class="label-muted">Content</div>
|
||||
<textarea name="content" rows="6" placeholder="Write something..."></textarea>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div class="label-muted" style="margin-bottom:6px;">Image</div>
|
||||
|
||||
<div class="file-wrap" style="align-items:center;">
|
||||
<label class="file-btn" for="image">Choose image</label>
|
||||
<span class="label-muted" id="file-name">No file chosen</span>
|
||||
<input id="image" type="file" name="image" accept=".png,.jpg,.jpeg,.bmp" onchange="handleFileChange(this)">
|
||||
</div>
|
||||
|
||||
<div id="preview-wrap" style="margin-top:12px; display:none;">
|
||||
<div class="label-muted" style="margin-bottom:6px;">Preview</div>
|
||||
<img id="preview" alt="preview" style="max-width:320px; border-radius:8px; border:1px solid rgba(255,255,255,0.03);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:10px; align-items:center;">
|
||||
<button class="btn" type="submit">Create</button>
|
||||
<button type="button" class="button ghost" onclick="resetFile()" style="background:transparent; color:var(--muted); border:1px solid rgba(255,255,255,0.04); padding:8px 12px; border-radius:8px;">
|
||||
Clear file
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function handleFileChange(input) {
|
||||
const file = input.files && input.files[0];
|
||||
const nameSpan = document.getElementById('file-name');
|
||||
const previewWrap = document.getElementById('preview-wrap');
|
||||
const preview = document.getElementById('preview');
|
||||
|
||||
if (!file) {
|
||||
nameSpan.innerText = 'No file chosen';
|
||||
previewWrap.style.display = 'none';
|
||||
preview.src = '';
|
||||
return;
|
||||
}
|
||||
|
||||
nameSpan.innerText = file.name;
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
preview.src = e.target.result;
|
||||
previewWrap.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
previewWrap.style.display = 'none';
|
||||
preview.src = '';
|
||||
}
|
||||
}
|
||||
|
||||
function resetFile() {
|
||||
const input = document.getElementById('image');
|
||||
input.value = '';
|
||||
document.getElementById('file-name').innerText = 'No file chosen';
|
||||
document.getElementById('preview-wrap').style.display = 'none';
|
||||
document.getElementById('preview').src = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
|
||||
<style>
|
||||
/* Small, page-specific responsive tweaks (safe to keep) */
|
||||
.posts-container{
|
||||
max-width:1200px;
|
||||
margin:18px auto;
|
||||
padding:0 16px;
|
||||
}
|
||||
|
||||
/* responsive grid: auto-fit columns, each at least 320px wide */
|
||||
.posts-grid{
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* each post card is a flexible grid: image column (if present) + content */
|
||||
.post-card{
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
padding: 14px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* if no image, make content span full width */
|
||||
.post-card.no-image{
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* thumbnail styling */
|
||||
.post-card img{
|
||||
width:100%;
|
||||
height:120px;
|
||||
object-fit:cover;
|
||||
border-radius:8px;
|
||||
display:block;
|
||||
border:1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
/* small adjustments for tighter screens */
|
||||
@media (max-width:640px){
|
||||
.post-card{ grid-template-columns: 1fr; }
|
||||
.post-card img{ height:200px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="posts-container">
|
||||
|
||||
<!-- Search -->
|
||||
<form method="GET" action="{{ url_for('index') }}" style="display:flex; gap:10px; margin-bottom:14px;">
|
||||
<input name="q" placeholder="Search posts..." value="{{ q|default('') }}" style="flex:1; padding:10px 12px; border-radius:10px; border:1px solid rgba(255,255,255,0.03); background:rgba(255,255,255,0.02); color:inherit;">
|
||||
<button class="btn" type="submit" style="min-width:100px;">Search</button>
|
||||
</form>
|
||||
|
||||
<h2 style="margin:0 0 12px 0">Posts</h2>
|
||||
|
||||
<div class="posts-grid">
|
||||
{% for p in posts %}
|
||||
{# determine if image exists to add no-image class #}
|
||||
<article class="card post-card {% if not p['image_filename'] %}no-image{% endif %}">
|
||||
{% if p['image_filename'] %}
|
||||
<div>
|
||||
<a href="{{ url_for('view_post', pid=p['id']) }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=p['image_filename']) }}" alt="img">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px 0; font-size:18px;">
|
||||
<a href="{{ url_for('view_post', pid=p['id']) }}" style="color:inherit; text-decoration:none;">
|
||||
{{ p['title'] or 'Untitled' }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px;">
|
||||
<span class="label-muted">By {{ p['author'] or 'unknown' }}</span>
|
||||
{% if p['author'] and p['author'] == session.get('username') %}
|
||||
<span class="badge">you</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p style="margin:0 0 10px 0; color:var(--muted);">
|
||||
{% if p['content'] %}
|
||||
{{ (p['content'][:200] + '...') if p['content']|length > 200 else p['content'] }}
|
||||
{% else %}
|
||||
<span class="label-muted">No content</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div style="display:flex; gap:10px; align-items:center; margin-top:8px;">
|
||||
<a class="btn" href="{{ url_for('view_post', pid=p['id']) }}" style="padding:8px 12px; font-size:14px;">Read</a>
|
||||
<span class="label-muted" style="font-size:13px;">Post ID: {{ p['id'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center; padding:24px;">
|
||||
<p style="margin:0; color:var(--muted);">No posts yet.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const q = document.querySelector('input[name="q"]');
|
||||
if (q) q.focus();
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Blogpost</title>
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Blogpost</h1>
|
||||
<nav>
|
||||
<a href="{{ url_for('index') }}">Home</a>
|
||||
{% if session.get('user_id') %}
|
||||
<a href="{{ url_for('create_post') }}">Create</a>
|
||||
<a href="{{ url_for('profile') }}">Profile</a>
|
||||
<a href="{{ url_for('logout') }}">Logout ({{ session.get('username') }})</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}">Login</a>
|
||||
<a href="{{ url_for('register') }}">Register</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<ul>
|
||||
{% for m in messages %}
|
||||
<li>{{ m }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<small>keii</small>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
|
||||
<!-- safe to include even if layout already loads it -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
|
||||
<div style="max-width:520px; margin:28px auto;">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<ul>
|
||||
{% for m in messages %}
|
||||
<li>{{ m }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card" style="padding:22px;">
|
||||
<h2 style="margin-top:0">Login</h2>
|
||||
|
||||
<form method="post" action="{{ url_for('login') }}" style="display:flex; flex-direction:column; gap:12px;">
|
||||
<label>
|
||||
<div class="label-muted">Username</div>
|
||||
<input name="username" id="username" type="text" placeholder="your username" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<div class="label-muted" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>Password</span>
|
||||
<a href="{{ url_for('register') }}" style="font-size:13px; color:var(--muted); text-decoration:none;">Create account</a>
|
||||
</div>
|
||||
<div style="position:relative;">
|
||||
<input name="password" id="password" type="password" placeholder="your password" required style="padding-right:92px;">
|
||||
<button type="button" id="pw-toggle" style="position:absolute; right:6px; top:6px; height:36px; border-radius:8px; border:1px solid rgba(255,255,255,0.04); background:transparent; color:var(--muted); padding:6px 10px; cursor:pointer;">
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div style="display:flex; gap:10px; align-items:center; justify-content:space-between;">
|
||||
<label style="display:flex; gap:8px; align-items:center; font-size:14px; color:var(--muted);">
|
||||
<input type="checkbox" name="remember" style="width:16px; height:16px;">
|
||||
Remember
|
||||
</label>
|
||||
|
||||
<button class="btn" type="submit" style="min-width:120px;">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// autofocus username
|
||||
document.getElementById('username')?.focus();
|
||||
|
||||
// password toggle
|
||||
(function(){
|
||||
const pw = document.getElementById('password');
|
||||
const btn = document.getElementById('pw-toggle');
|
||||
if (!pw || !btn) return;
|
||||
btn.addEventListener('click', () => {
|
||||
if (pw.type === 'password') {
|
||||
pw.type = 'text';
|
||||
btn.innerText = 'Hide';
|
||||
} else {
|
||||
pw.type = 'password';
|
||||
btn.innerText = 'Show';
|
||||
}
|
||||
pw.focus();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
<h2>Profile: {{ user['username'] }}</h2>
|
||||
<p>Role: {{ user['role'] }}</p>
|
||||
|
||||
{% if flag %}
|
||||
<h3>FLAG (admin only):</h3>
|
||||
<pre>{{ flag }}</pre>
|
||||
{% else %}
|
||||
<p>No special access.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
|
||||
<div style="max-width:520px; margin:28px auto;">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<ul>
|
||||
{% for m in messages %}
|
||||
<li>{{ m }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card" style="padding:22px;">
|
||||
<h2 style="margin-top:0">Register</h2>
|
||||
|
||||
<form method="post" action="{{ url_for('register') }}" style="display:flex; flex-direction:column; gap:12px;">
|
||||
<label>
|
||||
<div class="label-muted">Username</div>
|
||||
<input name="username" id="reg-username" type="text" placeholder="choose a username" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<div class="label-muted">Password</div>
|
||||
<div style="position:relative;">
|
||||
<input name="password" id="reg-password" type="password" placeholder="create a password" required style="padding-right:92px;">
|
||||
<button type="button" id="reg-pw-toggle" style="position:absolute; right:6px; top:6px; height:36px; border-radius:8px; border:1px solid rgba(255,255,255,0.04); background:transparent; color:var(--muted); padding:6px 10px; cursor:pointer;">
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div style="display:flex; gap:10px; align-items:center; justify-content:flex-end;">
|
||||
<a href="{{ url_for('login') }}" class="label-muted" style="text-decoration:none; color:var(--muted);">Already have an account?</a>
|
||||
<button class="btn" type="submit" style="min-width:120px;">Register</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// autofocus username
|
||||
document.getElementById('reg-username')?.focus();
|
||||
|
||||
// password toggle
|
||||
(function(){
|
||||
const pw = document.getElementById('reg-password');
|
||||
const btn = document.getElementById('reg-pw-toggle');
|
||||
if (!pw || !btn) return;
|
||||
btn.addEventListener('click', () => {
|
||||
if (pw.type === 'password') {
|
||||
pw.type = 'text';
|
||||
btn.innerText = 'Hide';
|
||||
} else {
|
||||
pw.type = 'password';
|
||||
btn.innerText = 'Show';
|
||||
}
|
||||
pw.focus();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block content %}
|
||||
<article>
|
||||
<h2>{{ post['title'] or 'Untitled' }}</h2>
|
||||
<p>By {{ post['author'] or 'unknown' }}</p>
|
||||
|
||||
{% if post['image_filename'] %}
|
||||
<img src="{{ url_for('uploaded_file', filename=post['image_filename']) }}" style="max-width:400px">
|
||||
{% endif %}
|
||||
|
||||
<p>{{ post['content'] }}</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user