server.py

← Back to explorer
server.py
Raw
Python 616 lines · UTF-8
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
import os
import base64
import mimetypes
from urllib.parse import quote

from flask import Flask, render_template, abort, request, redirect, url_for, session, flash, jsonify
from PIL import Image
from PIL.ExifTags import TAGS
from werkzeug.utils import secure_filename
from uuid import uuid4
import requests
import markdown as md
import bleach
from utils.metas import projects, github_projects
from utils.utils import header, fetch_github_contents, get_project_metadata, get_file_summary, load_activity_data
from utils.blog import (
    ensure_blog_schema,
    get_blog_db_connection,
    slugify,
    client_anon_hash,
    utcnow,
)

app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY") or os.urandom(32)

UPLOAD_FOLDER = os.path.join("static", "uploads")
PHOTOS_FOLDER = os.path.join("static", "photos")
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp", "gif"}

app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["PHOTOS_FOLDER"] = PHOTOS_FOLDER


def _ensure_upload_dir():
    os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)

def _ensure_photos_dir():
    os.makedirs(app.config["PHOTOS_FOLDER"], exist_ok=True)

def _allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS

def _save_upload(file_storage):
    if not file_storage or not getattr(file_storage, "filename", ""):
        return None
    filename = secure_filename(file_storage.filename)
    if not filename or not _allowed_file(filename):
        return None
    ext = filename.rsplit(".", 1)[1].lower()
    stored = f"{uuid4().hex}.{ext}"
    _ensure_upload_dir()
    file_storage.save(os.path.join(app.config["UPLOAD_FOLDER"], stored))
    return stored

def _save_photo(file_storage):
    if not file_storage or not getattr(file_storage, "filename", ""):
        return None
    filename = secure_filename(file_storage.filename)
    if not filename or not _allowed_file(filename):
        return None
    ext = filename.rsplit(".", 1)[1].lower()
    stored = f"{uuid4().hex}.{ext}"
    _ensure_photos_dir()
    file_storage.save(os.path.join(app.config["PHOTOS_FOLDER"], stored))
    return stored

def _rational_str(val):
    try:
        if hasattr(val, 'numerator'):
            n, d = val.numerator, val.denominator
        elif isinstance(val, tuple) and len(val) == 2:
            n, d = val
        else:
            return str(val)
        if d == 0:
            return str(n)
        return str(n // d) if n % d == 0 else f"{n}/{d}"
    except Exception:
        return str(val)

def extract_photo_exif(filepath):
    try:
        img = Image.open(filepath)
        raw_exif = img._getexif()
    except Exception:
        return {}
    if not raw_exif:
        return {}
    raw = {TAGS.get(tid, str(tid)): v for tid, v in raw_exif.items()}
    result = {}
    make  = str(raw.get('Make',  '') or '').strip()
    model = str(raw.get('Model', '') or '').strip()
    if make and model and not model.startswith(make):
        result['camera'] = f"{make} {model}"
    elif model:
        result['camera'] = model
    lens = raw.get('LensModel') or raw.get('LensSpecification')
    if isinstance(lens, (list, tuple)):
        lens = ' '.join(_rational_str(x) for x in lens)
    if lens:
        result['lens'] = str(lens).strip()
    fl = raw.get('FocalLength')
    if fl is not None:
        result['focal_length'] = _rational_str(fl) + 'mm'
    fnum = raw.get('FNumber')
    if fnum is not None:
        result['aperture'] = 'f/' + _rational_str(fnum)
    exp = raw.get('ExposureTime')
    if exp is not None:
        result['shutter_speed'] = _rational_str(exp) + 's'
    iso = raw.get('ISOSpeedRatings') or raw.get('PhotographicSensitivity')
    if iso is not None:
        result['iso'] = str(iso)
    dt = raw.get('DateTimeOriginal') or raw.get('DateTime')
    if dt:
        result['date_taken'] = str(dt).replace(':', '-', 2).split(' ')[0]
    return result

def ensure_photos_schema():
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("""
                CREATE TABLE IF NOT EXISTS photos (
                    id SERIAL PRIMARY KEY,
                    filename TEXT NOT NULL,
                    original_url TEXT,
                    title TEXT,
                    camera TEXT,
                    lens TEXT,
                    focal_length TEXT,
                    aperture TEXT,
                    shutter_speed TEXT,
                    iso TEXT,
                    date_taken TEXT,
                    sort_order INTEGER NOT NULL DEFAULT 0,
                    created_at TIMESTAMP NOT NULL DEFAULT NOW()
                );
            """)
        conn.commit()
    finally:
        conn.close()

@app.before_request
def _ensure_schemas_once():
    if not getattr(app, "_schemas_ready", False):
        ensure_blog_schema()
        ensure_photos_schema()
        app._schemas_ready = True

def _is_admin_logged_in():
    return bool(session.get("is_admin"))

def _require_admin():
    if not _is_admin_logged_in():
        return redirect(url_for("admin_login", next=request.path))
    return None

def _render_markdown_safe(markdown_text):
    html = md.markdown(
        markdown_text or "",
        extensions=["fenced_code", "tables", "codehilite"],
        output_format="html5",
    )
    allowed_tags = set(bleach.sanitizer.ALLOWED_TAGS).union({
        "p","pre","code","h1","h2","h3","h4","h5","h6",
        "img","blockquote","hr","br","span","div",
        "table","thead","tbody","tr","th","td",
    })
    allowed_attrs = {
        **bleach.sanitizer.ALLOWED_ATTRIBUTES,
        "a": ["href","title","rel","target"],
        "img": ["src","alt","title"],
        "*": ["class"],
    }
    return bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs, strip=True)


@app.route("/")
def home():
    return render_template("main.html")

@app.route("/activity")
def activity():
    days = load_activity_data()
    latest_three = days[-3:]
    return render_template("activity.html", days=days, latest_three=latest_three)

@app.route("/activity/raw")
def activity_raw():
    days = load_activity_data()
    return render_template("activity_raw.html", days=days)

@app.route("/projects")
def projects_view():
    return render_template("projects.html", projects=projects)

@app.route("/blog")
def blog_index():
    q = (request.args.get("q") or "").strip()
    tag = (request.args.get("tag") or "").strip()
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            if q and tag:
                like = f"%{q}%"
                cur.execute("""
                    SELECT p.id,p.slug,p.title,p.excerpt,p.published_at,p.cover_image,p.tags,COUNT(l.id)
                    FROM blog_posts p LEFT JOIN blog_likes l ON p.id=l.post_id
                    WHERE p.is_published=TRUE AND (%s=ANY(p.tags))
                      AND (p.title ILIKE %s OR p.excerpt ILIKE %s OR p.content_md ILIKE %s)
                    GROUP BY p.id ORDER BY p.published_at DESC NULLS LAST,p.created_at DESC LIMIT 50
                """,(tag,like,like,like))
            elif q:
                like = f"%{q}%"
                cur.execute("""
                    SELECT p.id,p.slug,p.title,p.excerpt,p.published_at,p.cover_image,p.tags,COUNT(l.id)
                    FROM blog_posts p LEFT JOIN blog_likes l ON p.id=l.post_id
                    WHERE p.is_published=TRUE
                      AND (p.title ILIKE %s OR p.excerpt ILIKE %s OR p.content_md ILIKE %s)
                    GROUP BY p.id ORDER BY p.published_at DESC NULLS LAST,p.created_at DESC LIMIT 50
                """,(like,like,like))
            elif tag:
                cur.execute("""
                    SELECT p.id,p.slug,p.title,p.excerpt,p.published_at,p.cover_image,p.tags,COUNT(l.id)
                    FROM blog_posts p LEFT JOIN blog_likes l ON p.id=l.post_id
                    WHERE p.is_published=TRUE AND (%s=ANY(p.tags))
                    GROUP BY p.id ORDER BY p.published_at DESC NULLS LAST,p.created_at DESC LIMIT 50
                """,(tag,))
            else:
                cur.execute("""
                    SELECT p.id,p.slug,p.title,p.excerpt,p.published_at,p.cover_image,p.tags,COUNT(l.id)
                    FROM blog_posts p LEFT JOIN blog_likes l ON p.id=l.post_id
                    WHERE p.is_published=TRUE
                    GROUP BY p.id ORDER BY p.published_at DESC NULLS LAST,p.created_at DESC LIMIT 50
                """)
            rows = cur.fetchall()
    finally:
        conn.close()
    posts = [{"id":r[0],"slug":r[1],"title":r[2],"excerpt":r[3],"published_at":r[4],"cover_image":r[5],"tags":r[6] or [],"like_count":r[7]} for r in rows]
    return render_template("blog_index.html", posts=posts, q=q, tag=tag)

@app.route("/blog/<slug>")
def blog_post(slug):
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("""
                SELECT id,slug,title,excerpt,content_html,published_at,cover_image,tags
                FROM blog_posts WHERE slug=%s AND is_published=TRUE
            """,(slug,))
            row = cur.fetchone()
            if not row: abort(404)
            post_id = row[0]
            cur.execute("SELECT COUNT(*) FROM blog_likes WHERE post_id=%s",(post_id,))
            like_count = int(cur.fetchone()[0])
            cur.execute("""
                SELECT COALESCE(name,display_name,'Anonymous'),content,created_at
                FROM blog_comments WHERE post_id=%s AND is_hidden=FALSE
                ORDER BY created_at DESC LIMIT 200
            """,(post_id,))
            comments = [{"display_name":c[0],"content":c[1],"created_at":c[2]} for c in cur.fetchall()]
            cur.execute("""
                SELECT filename FROM blog_images WHERE post_id=%s ORDER BY sort_order ASC,id ASC
            """,(post_id,))
            images = [{"filename":r[0]} for r in cur.fetchall()]
            anon = client_anon_hash(request)
            cur.execute("SELECT 1 FROM blog_likes WHERE post_id=%s AND anon_hash=%s",(post_id,anon))
            has_liked = bool(cur.fetchone())
    finally:
        conn.close()
    post = {"id":row[0],"slug":row[1],"title":row[2],"excerpt":row[3],"content_html":row[4],"published_at":row[5],"cover_image":row[6],"tags":row[7] or []}
    return render_template("blog_post.html", post=post, like_count=like_count, has_liked=has_liked, comments=comments, images=images)

@app.route("/api/blog/<int:post_id>/like", methods=["POST"])
def blog_like(post_id):
    anon = client_anon_hash(request)
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT id FROM blog_likes WHERE post_id=%s AND anon_hash=%s",(post_id,anon))
            existing = cur.fetchone()
            if existing:
                cur.execute("DELETE FROM blog_likes WHERE id=%s",(existing[0],))
                liked = False
            else:
                cur.execute("INSERT INTO blog_likes (post_id,anon_hash) VALUES (%s,%s) ON CONFLICT DO NOTHING",(post_id,anon))
                liked = True
            cur.execute("SELECT COUNT(*) FROM blog_likes WHERE post_id=%s",(post_id,))
            count = int(cur.fetchone()[0])
        conn.commit()
    finally:
        conn.close()
    return jsonify({"ok":True,"liked":liked,"count":count})

@app.route("/api/blog/<int:post_id>/comment", methods=["POST"])
def blog_comment(post_id):
    data = request.get_json(silent=True) or {}
    content = (data.get("content") or "").strip()
    name = (data.get("display_name") or data.get("name") or "Anonymous").strip() or "Anonymous"
    if not content or len(content) > 2000:
        return jsonify({"ok":False,"error":"Comment is empty or too long."}), 400
    if len(name) > 40: name = name[:40]
    anon = client_anon_hash(request)
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("INSERT INTO blog_comments (post_id,anon_hash,name,content) VALUES (%s,%s,%s,%s)",(post_id,anon,name,content))
        conn.commit()
    finally:
        conn.close()
    return jsonify({"ok":True})

@app.route("/admin/login", methods=["GET","POST"])
def admin_login():
    if request.method == "POST":
        username = (request.form.get("username") or "").strip()
        password = (request.form.get("password") or "").strip()
        expected_user = os.getenv("ADMIN_USERNAME") or "admin"
        expected_pass = os.getenv("ADMIN_PASSWORD") or "admin"
        if username == expected_user and password == expected_pass:
            session["is_admin"] = True
            nxt = request.args.get("next") or url_for("admin_photos")
            return redirect(nxt)
        flash("Invalid credentials.")
    return render_template("admin_login.html")

@app.route("/admin/logout")
def admin_logout():
    session.clear()
    return redirect(url_for("home"))

@app.route("/admin")
@app.route("/admin/blog")
def admin_posts():
    guard = _require_admin()
    if guard: return guard
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT id,slug,title,is_published,created_at,published_at FROM blog_posts ORDER BY created_at DESC LIMIT 200")
            rows = cur.fetchall()
    finally:
        conn.close()
    posts = [{"id":r[0],"slug":r[1],"title":r[2],"is_published":r[3],"created_at":r[4],"published_at":r[5]} for r in rows]
    return render_template("admin_posts.html", posts=posts)

@app.route("/admin/photos")
def admin_photos():
    guard = _require_admin()
    if guard: return guard
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("""
                SELECT id,filename,original_url,title,camera,lens,
                       focal_length,aperture,shutter_speed,iso,date_taken,sort_order
                FROM photos ORDER BY sort_order ASC,created_at DESC
            """)
            rows = cur.fetchall()
    finally:
        conn.close()
    photos = [{"id":r[0],"filename":r[1],"original_url":r[2],"title":r[3],"camera":r[4],"lens":r[5],"focal_length":r[6],"aperture":r[7],"shutter_speed":r[8],"iso":r[9],"date_taken":r[10],"sort_order":r[11]} for r in rows]
    return render_template("admin_photos.html", photos=photos)

@app.route("/admin/photos/upload", methods=["POST"])
def admin_photos_upload():
    guard = _require_admin()
    if guard: return guard
    file = request.files.get("photo")
    if not file:
        flash("No file selected.")
        return redirect(url_for("admin_photos"))
    stored = _save_photo(file)
    if not stored:
        flash("Invalid file type. Use webp, jpg, png, gif.")
        return redirect(url_for("admin_photos"))

    saved_path = os.path.join(app.config["PHOTOS_FOLDER"], stored)
    auto = extract_photo_exif(saved_path)

    def field(name):
        v = (request.form.get(name) or "").strip()
        return v or auto.get(name) or None

    original_url  = (request.form.get("original_url") or "").strip() or None
    title         = field("title")
    camera        = field("camera")
    lens          = field("lens")
    focal_length  = field("focal_length")
    aperture      = field("aperture")
    shutter_speed = field("shutter_speed")
    iso           = field("iso")
    date_taken    = field("date_taken")
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT COALESCE(MAX(sort_order),-1) FROM photos")
            max_order = cur.fetchone()[0]
            cur.execute("""
                INSERT INTO photos (filename,original_url,title,camera,lens,focal_length,aperture,shutter_speed,iso,date_taken,sort_order)
                VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            """,(stored,original_url,title,camera,lens,focal_length,aperture,shutter_speed,iso,date_taken,max_order+1))
        conn.commit()
    finally:
        conn.close()
    flash("Photo uploaded successfully.")
    return redirect(url_for("admin_photos"))

@app.route("/admin/photos/<int:photo_id>/delete", methods=["POST"])
def admin_photos_delete(photo_id):
    guard = _require_admin()
    if guard: return guard
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT filename FROM photos WHERE id=%s",(photo_id,))
            row = cur.fetchone()
            if row:
                filepath = os.path.join(app.config["PHOTOS_FOLDER"], row[0])
                if os.path.exists(filepath):
                    os.remove(filepath)
                cur.execute("DELETE FROM photos WHERE id=%s",(photo_id,))
        conn.commit()
    finally:
        conn.close()
    flash("Photo deleted.")
    return redirect(url_for("admin_photos"))

@app.route("/admin/blog/new", methods=["GET","POST"])
def admin_new_post():
    guard = _require_admin()
    if guard: return guard
    if request.method == "POST":
        title = (request.form.get("title") or "").strip()
        excerpt = (request.form.get("excerpt") or "").strip()
        tags_raw = (request.form.get("tags") or "").strip()
        slug = slugify((request.form.get("slug") or "").strip() or title)
        cover_image = _save_upload(request.files.get("cover_image"))
        uploaded_images = request.files.getlist("post_images")
        image_files = [fn for fn in (_save_upload(f) for f in uploaded_images) if fn]
        content_md = (request.form.get("content_md") or "").strip()
        is_published = bool(request.form.get("is_published"))
        if not title or not content_md:
            flash("Title and content are required.")
            return render_template("admin_edit.html", post=None)
        tags = [t.strip() for t in tags_raw.replace(";",",").split(",") if t.strip()]
        seen = set()
        tags = [t for t in tags if not (t.lower() in seen or seen.add(t.lower()))]
        content_html = _render_markdown_safe(content_md)
        published_at = utcnow() if is_published else None
        conn = get_blog_db_connection()
        try:
            with conn.cursor() as cur:
                cur.execute("""
                    INSERT INTO blog_posts (slug,title,excerpt,tags,cover_image,content_md,content_html,is_published,created_at,updated_at,published_at)
                    VALUES (%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW(),%s) RETURNING id
                """,(slug,title,excerpt,tags,cover_image,content_md,content_html,is_published,published_at))
                new_id = cur.fetchone()[0]
                if image_files:
                    for i,fn in enumerate(image_files):
                        cur.execute("INSERT INTO blog_images (post_id,filename,sort_order) VALUES (%s,%s,%s)",(new_id,fn,i))
            conn.commit()
        finally:
            conn.close()
        return redirect(url_for("admin_edit_post", post_id=new_id))
    return render_template("admin_edit.html", post=None)

@app.route("/admin/blog/<int:post_id>/edit", methods=["GET","POST"])
def admin_edit_post(post_id):
    guard = _require_admin()
    if guard: return guard
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT id,slug,title,excerpt,tags,cover_image,content_md,is_published FROM blog_posts WHERE id=%s",(post_id,))
            row = cur.fetchone()
            if not row: abort(404)
            cur.execute("SELECT filename FROM blog_images WHERE post_id=%s ORDER BY sort_order ASC,id ASC",(post_id,))
            existing_images = [r[0] for r in cur.fetchall()]
            if request.method == "POST":
                title = (request.form.get("title") or "").strip()
                excerpt = (request.form.get("excerpt") or "").strip()
                tags_raw = (request.form.get("tags") or "").strip()
                remove_cover = bool(request.form.get("remove_cover"))
                remove_gallery = bool(request.form.get("remove_gallery"))
                new_cover = _save_upload(request.files.get("cover_image"))
                content_md = (request.form.get("content_md") or "").strip()
                is_published = bool(request.form.get("is_published"))
                slug = slugify(request.form.get("slug") or title)
                uploaded_images = request.files.getlist("post_images")
                new_images = [fn for fn in (_save_upload(f) for f in uploaded_images) if fn]
                if not title or not content_md:
                    flash("Title and content are required.")
                else:
                    tags = [t.strip() for t in tags_raw.replace(";",",").split(",") if t.strip()]
                    seen = set()
                    tags = [t for t in tags if not (t.lower() in seen or seen.add(t.lower()))]
                    cur.execute("SELECT cover_image FROM blog_posts WHERE id=%s",(post_id,))
                    current_cover = cur.fetchone()[0]
                    cover_value = None if remove_cover else (new_cover or current_cover)
                    content_html = _render_markdown_safe(content_md)
                    now = utcnow()
                    if is_published:
                        cur.execute("SELECT published_at FROM blog_posts WHERE id=%s",(post_id,))
                        existing_pub = cur.fetchone()[0]
                        published_at = existing_pub or now
                    else:
                        published_at = None
                    cur.execute("""
                        UPDATE blog_posts SET slug=%s,title=%s,excerpt=%s,tags=%s,cover_image=%s,
                        content_md=%s,content_html=%s,is_published=%s,updated_at=NOW(),published_at=%s
                        WHERE id=%s
                    """,(slug,title,excerpt,tags,cover_value,content_md,content_html,is_published,published_at,post_id))
                    if remove_gallery:
                        cur.execute("DELETE FROM blog_images WHERE post_id=%s",(post_id,))
                    if new_images:
                        cur.execute("SELECT COALESCE(MAX(sort_order),-1) FROM blog_images WHERE post_id=%s",(post_id,))
                        start_order = (cur.fetchone()[0] or -1) + 1
                        for i,fn in enumerate(new_images):
                            cur.execute("INSERT INTO blog_images (post_id,filename,sort_order) VALUES (%s,%s,%s)",(post_id,fn,start_order+i))
                    conn.commit()
                    flash("Saved.")
            post = {"id":row[0],"slug":row[1],"title":row[2],"excerpt":row[3],"tags":row[4] or [],"cover_image":row[5],"content_md":row[6],"is_published":row[7],"images":existing_images}
            cur.execute("SELECT slug,title,excerpt,tags,cover_image,content_md,is_published FROM blog_posts WHERE id=%s",(post_id,))
            r2 = cur.fetchone()
            post.update({"slug":r2[0],"title":r2[1],"excerpt":r2[2],"tags":r2[3] or [],"cover_image":r2[4],"content_md":r2[5],"is_published":r2[6]})
            cur.execute("SELECT filename FROM blog_images WHERE post_id=%s ORDER BY sort_order ASC,id ASC",(post_id,))
            post["images"] = [r[0] for r in cur.fetchall()]
    finally:
        conn.close()
    return render_template("admin_edit.html", post=post)

@app.route("/admin/blog/<int:post_id>/delete", methods=["POST"])
def admin_delete_post(post_id):
    guard = _require_admin()
    if guard: return guard
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("DELETE FROM blog_posts WHERE id=%s",(post_id,))
        conn.commit()
    finally:
        conn.close()
    return redirect(url_for("admin_posts"))

@app.route("/projects/<project_name>/", defaults={"folder_path":""})
@app.route("/projects/<project_name>/<path:folder_path>")
def project_detail(project_name, folder_path):
    repository_name = github_projects.get(project_name)
    if not repository_name: abort(404)
    structure = fetch_github_contents(repository_name, path=folder_path)
    if structure is None:
        return render_template("project_detail.html",project_name=project_name,project_display_name=project_name.replace("-"," ").title(),structure=[],folder_path=folder_path,metadata=None,file_summary=None,error="GitHub API error or rate limit exceeded.")
    metadata = get_project_metadata(repository_name, os.getenv("TOKEN"))
    file_summary = get_file_summary(repository_name, os.getenv("TOKEN"))
    return render_template("project_detail.html",project_name=project_name,project_display_name=project_name.replace("-"," ").title(),structure=structure,folder_path=folder_path,metadata=metadata,file_summary=file_summary,error=None)

@app.route("/projects/<project_name>/file/<path:file_path>")
def view_file(project_name, file_path):
    repository_name = github_projects.get(project_name)
    if not repository_name: abort(404)
    url = f"https://api.github.com/repos/{repository_name}/contents/{file_path}"
    response = requests.get(url, headers=header)
    if response.status_code != 200: abort(404)
    content_json = response.json()
    file_content = None
    image_data_url = None
    if content_json.get("encoding") == "base64":
        decoded_bytes = base64.b64decode(content_json["content"])
        file_ext = file_path.split(".")[-1].lower() if "." in file_path else ""
        if file_ext in ["png","jpg","jpeg","gif","webp","svg"]:
            mime_type = mimetypes.guess_type(file_path)[0] or "image/png"
            base64_data = base64.b64encode(decoded_bytes).decode("utf-8")
            image_data_url = f"data:{mime_type};base64,{base64_data}"
        else:
            file_content = decoded_bytes.decode("utf-8", errors="replace")
    else:
        file_content = "Cannot display content."
    file_name = file_path.split("/")[-1]
    file_ext = file_name.split(".")[-1] if "." in file_name else ""
    return render_template("file_viewer.html",project_name=project_name,file_path=file_path,file_name=file_name,file_content=file_content,file_ext=file_ext,image_data_url=image_data_url)

@app.route("/photos")
def photos():
    conn = get_blog_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("""
                SELECT id,filename,original_url,title,camera,lens,focal_length,aperture,shutter_speed,iso,date_taken
                FROM photos ORDER BY sort_order ASC,created_at DESC
            """)
            rows = cur.fetchall()
    finally:
        conn.close()
    photos_list = [{"id":r[0],"filename":r[1],"original_url":r[2],"title":r[3],"camera":r[4],"lens":r[5],"focal_length":r[6],"aperture":r[7],"shutter_speed":r[8],"iso":r[9],"date_taken":r[10],"url":url_for("static",filename=f"photos/{r[1]}")} for r in rows]
    return render_template("photos.html", photos=photos_list)

@app.route("/contacts")
def contacts():
    links = [
        {"name":"GitHub","href":"https://github.com/AalbatrossGuy","icon":"GitHub.svg","subtitle":"github.com/AalbatrossGuy"},
        {"name":"LinkedIn","href":"https://linkedin.com/in/kishaloy-roy","icon":"LinkedIn.svg","subtitle":"linkedin.com/KishaloyRoy"},
        {"name":"Reddit","href":"https://reddit.com/user/AalbatrossGuy","icon":"RedditIcon.svg","subtitle":"reddit.com/AalbatrossGuy"},
        {"name":"Email","href":"mailto:[email protected]","icon":"email.png","subtitle":"[email protected]"},
        {"name":"Instagram","href":"https://instagram.com/aalbatrossguy","icon":"InstagramIcon.svg","subtitle":"instagram.com/aalbatrossguy"},
        {"name":"Monkeytype","href":"https://monkeytype.com/profile/AalbatrossGuy","icon":"MonkeyTypeIcon.svg","subtitle":"monkeytype.com/AalbatrossGuy"},
        {"name":"Stack Overflow","href":"https://stackoverflow.com/users/13810518/aag","icon":"Stack_Overflow.svg","subtitle":"stackoverflow.com/AalbatrossGuy"},
        {"name":"Matrix","href":"https://matrix.to/#/@aalbatrossguyy:matrix.org","icon":"MatrixIcon.svg","subtitle":"@AalbatrossGuy"},
        {"name":"Discord","href":"https://discord.com/users/676414187131371520","icon":"DiscordIcon.svg","subtitle":"@aalbatrossguy"},
    ]
    return render_template("contacts.html", links=links)

if __name__ == "__main__":
    app.run(host="0.0.0.0", debug=True)