#!/usr/bin/env python3
"""
PhotoDrop — hardened / clinical variant.

Self-contained LAN photo drop that is offline and access-controlled:
  - UI is fully INLINE — hand-written CSS, emoji icons, system fonts. NO CDN, so it renders with
    **no internet** (the plain variant loaded Tailwind/Lucide/fonts from CDNs).
  - QR code is generated **locally** with `segno` — no api.qrserver.com, so the LAN URL never leaves
    the network.
  - A per-session **access token** (ridden inside the QR) gates every request, so only a phone that
    scanned THIS screen's QR can view the gallery or upload. Set PHOTODROP_TOKEN to pin your own.

Requires: `pip install segno`  (one small pure-python dependency, for offline QR).

NOTE: still plain HTTP — the token blocks other devices on the Wi-Fi but is NOT encryption. For a
hardened clinical deployment add TLS (see the recipe's "Encryption" section).
"""
import argparse
import http.server
import json
import os
import secrets
import shutil
import socket
import socketserver
import ssl
import subprocess
import sys
import time
import urllib.parse
import webbrowser

import segno  # pip install segno

# --- CONFIG ---
PORT = 8000
UPLOAD_DIR = "dropped_photos"
# Per-session access token (rides in the QR). Override with PHOTODROP_TOKEN for a fixed value.
TOKEN = os.environ.get("PHOTODROP_TOKEN") or secrets.token_urlsafe(6)
SCHEME = "http"  # becomes "https" with --https, so the QR's embedded URL matches

HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PhotoDrop LAN</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E📶%3C/text%3E%3C/svg%3E">
<style>
  :root{--blue:#2563eb;--slate:#0f172a;--muted:#64748b;--bg:#f8fafc;--line:#e2e8f0}
  *{box-sizing:border-box;margin:0;padding:0}
  body{font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--slate);min-height:100vh}
  header{background:#fff;border-bottom:1px solid var(--line);padding:16px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:10}
  .brand{font-size:20px;font-weight:700}.brand b{color:var(--blue)}
  .badge{font:12px ui-monospace,monospace;background:#f1f5f9;color:var(--muted);padding:4px 12px;border-radius:999px}
  main{max-width:1100px;margin:0 auto;padding:24px;display:grid;grid-template-columns:1fr;gap:24px}
  @media(min-width:900px){main{grid-template-columns:320px 1fr}}
  .card{background:#fff;border:1px solid var(--line);border-radius:16px;padding:24px}
  .qr-card{text-align:center}.qr-card h2{font-size:18px;margin-bottom:6px}.qr-card p{color:var(--muted);font-size:14px;margin-bottom:16px}
  .qr-box{display:inline-block;padding:8px;border:1px solid var(--line);border-radius:12px}
  .qr-box img{width:200px;height:200px;display:block;image-rendering:pixelated}
  .host{margin-top:16px;display:inline-block;background:#eff6ff;color:var(--blue);font:12px ui-monospace,monospace;padding:6px 14px;border-radius:999px}
  .count-card{background:var(--slate);color:#fff;margin-top:16px}
  .count-card .n{font-size:36px;font-weight:700}.count-card p{color:#94a3b8;font-size:14px}
  .count-card .path{margin-top:12px;padding-top:12px;border-top:1px solid #334155;font:12px ui-monospace,monospace;color:#eab308}
  h2.g{font-size:22px;font-weight:700;margin-bottom:16px}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px}
  .photo{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden}
  .photo img{width:100%;height:120px;object-fit:cover;display:block;background:#f1f5f9}
  .photo .meta{padding:10px;display:flex;align-items:center;justify-content:space-between;gap:8px}
  .photo .meta span{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .photo .meta a{color:var(--blue);text-decoration:none;font-size:16px}
  .empty{border:2px dashed var(--line);border-radius:16px;height:280px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:6px}
  .empty .ico{font-size:40px;opacity:.5}
  .mwrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;background:#f1f5f9}
  .mcard{width:100%;max-width:420px;background:#fff;border-radius:20px;overflow:hidden;box-shadow:0 20px 40px rgba(0,0,0,.1)}
  .mhead{background:var(--blue);color:#fff;padding:32px;text-align:center}.mhead .ico{font-size:44px}.mhead h1{margin-top:12px;font-size:24px}
  .mbody{padding:32px;text-align:center}
  .drop{margin-top:8px;border:2px dashed #bfdbfe;background:#eff6ff;border-radius:14px;height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer}
  .drop .ico{font-size:40px;margin-bottom:12px}.drop b{color:var(--blue);font-size:20px}
  .drop input{display:none}
</style></head>
<body><div id="app"></div>
<script>
const token = new URLSearchParams(location.search).get('token') || '';
const qs = 'token=' + encodeURIComponent(token);
const state = {mode:'loading', files:[], uploading:false, status:'', host:location.host};
function init(){
  const p = new URLSearchParams(location.search);
  state.mode = p.get('mode')==='mobile' ? 'mobile' : 'desktop';
  render();
  if(state.mode==='desktop'){ poll(); setInterval(poll,2000); }
}
async function poll(){
  try{ const r=await fetch('/api/list?'+qs); if(!r.ok) return; const d=await r.json();
    if(d.length!==state.files.length){ state.files=d.sort((a,b)=>b.timestamp-a.timestamp); render(); } }catch(e){}
}
async function upload(e){
  const files=e.target.files; if(!files.length) return;
  state.uploading=true; render(); let ok=0;
  for(let i=0;i<files.length;i++){ state.status='Sending '+(i+1)+' of '+files.length+'...'; render();
    try{ await fetch('/api/upload?name='+encodeURIComponent(files[i].name)+'&'+qs,{method:'POST',body:files[i]}); ok++; }catch(err){} }
  state.status='Sent '+ok+' photo(s)!'; render();
  setTimeout(()=>{ state.uploading=false; state.status=''; render(); },2500);
}
function render(){
  const app=document.getElementById('app');
  if(state.mode==='mobile'){
    const body = state.uploading
      ? '<div class="mbody"><div style="font-size:44px">&#9989;</div><p style="margin-top:12px;font-size:18px">'+state.status+'</p></div>'
      : '<div class="mbody"><p style="color:#64748b;font-size:17px;margin-bottom:8px">Select photos to send to the host computer.</p>'
        +'<label class="drop" style="height:140px"><input type="file" accept="image/*" capture="environment" onchange="upload(event)">'
        +'<div class="ico">&#128247;</div><b>Take a Photo</b></label>'
        +'<label class="drop" style="margin-top:12px;height:140px"><input type="file" multiple accept="image/*" onchange="upload(event)">'
        +'<div class="ico">&#128444;&#65039;</div><b>Choose from Gallery</b></label></div>';
    app.innerHTML='<div class="mwrap"><div class="mcard"><div class="mhead"><div class="ico">&#128241;</div><h1>Connected</h1></div>'+body+'</div></div>';
    return;
  }
  const header='<header><div class="brand">&#128246; PhotoDrop <b>LAN</b></div><div class="badge">Host: '+state.host+'</div></header>';
  const gallery = state.files.length===0
    ? '<div class="empty"><div class="ico">&#128247;</div><p style="font-size:18px;font-weight:600">No photos yet</p><p style="font-size:14px">Scan the code to drop photos here</p></div>'
    : '<div class="grid">'+state.files.map(f=>'<div class="photo"><img src="'+f.url+'?'+qs+'"><div class="meta"><span>'+f.name+'</span><a href="'+f.url+'?'+qs+'" download>&#11015;</a></div></div>').join('')+'</div>';
  app.innerHTML=header+'<main>'
    +'<div><div class="card qr-card"><h2>Connect Device</h2><p>Scan with a phone on the same Wi-Fi.</p>'
    +'<div class="qr-box"><img src="{{QR_IMG}}" alt="QR"></div><div class="host">'+state.host+'</div></div>'
    +'<div class="card count-card"><div class="n">'+state.files.length+'</div><p>Photos Received</p>'
    +'<div class="path">Saving to: ./{{UPLOAD_DIR}}/</div></div></div>'
    +'<div><h2 class="g">Gallery</h2>'+gallery+'</div></main>';
}
init();
</script></body></html>"""

DENIED_HTML = """<!DOCTYPE html><html><head><meta charset="UTF-8"><title>PhotoDrop</title>
<style>body{font-family:-apple-system,"Segoe UI",sans-serif;background:#f1f5f9;color:#64748b}
.d{max-width:420px;margin:80px auto;text-align:center}</style></head>
<body><div class="d"><div style="font-size:48px">&#128274;</div>
<h2 style="color:#0f172a;margin:12px 0">Access denied</h2>
<p>Open PhotoDrop from the host screen, or scan the QR code shown there.</p></div></body></html>"""


def get_local_ip():
    """Find the LAN IP via the standard UDP-route trick (no packet is sent)."""
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
    except Exception:
        ip = "127.0.0.1"
    finally:
        s.close()
    return ip


def _token_ok(path):
    q = urllib.parse.parse_qs(urllib.parse.urlparse(path).query)
    return secrets.compare_digest((q.get("token") or [""])[0], TOKEN)


class Handler(http.server.SimpleHTTPRequestHandler):
    def _send(self, code, body, ctype="text/html"):
        b = body if isinstance(body, bytes) else body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-type", ctype)
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def do_GET(self):
        p = urllib.parse.urlparse(self.path).path
        if p == "/favicon.ico":
            # Served ahead of the token gate: browsers request the favicon automatically
            # with no token, and a 403 here just litters the console. Inline emoji SVG.
            svg = ("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>"
                   "<text y='.9em' font-size='90'>\U0001F4F6</text></svg>")
            self._send(200, svg, "image/svg+xml")
            return
        if not _token_ok(self.path):
            self._send(403, DENIED_HTML)
            return
        if p == "/api/list":
            files = []
            if os.path.isdir(UPLOAD_DIR):
                for f in os.listdir(UPLOAD_DIR):
                    if not f.startswith("."):
                        fp = os.path.join(UPLOAD_DIR, f)
                        files.append({"name": f, "url": f"/{UPLOAD_DIR}/{f}",
                                      "timestamp": os.path.getmtime(fp)})
            self._send(200, json.dumps(files), "application/json")
            return
        if p.startswith(f"/{UPLOAD_DIR}/"):
            self.path = p  # drop the ?token query so the file handler serves the file
            super().do_GET()
            return
        # Frontend page — inject a locally-generated QR (offline) + the upload dir.
        # PHOTODROP_HOST overrides the auto-detected IP so a containerized / reverse-proxied
        # instance can advertise the host's LAN IP (get_local_ip() would return the container IP).
        ip = os.environ.get("PHOTODROP_HOST") or get_local_ip()
        mobile_url = f"{SCHEME}://{ip}:{PORT}/?mode=mobile&token={TOKEN}"
        qr_uri = segno.make(mobile_url, error="m").png_data_uri(scale=6, border=2)
        html = HTML_TEMPLATE.replace("{{QR_IMG}}", qr_uri).replace("{{UPLOAD_DIR}}", UPLOAD_DIR)
        self._send(200, html)

    def do_POST(self):
        if not _token_ok(self.path):
            self._send(403, "denied")
            return
        if urllib.parse.urlparse(self.path).path == "/api/upload":
            try:
                q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
                name = os.path.basename((q.get("name") or ["unknown.png"])[0])
                n = int(self.headers["Content-Length"])
                data = self.rfile.read(n)
                os.makedirs(UPLOAD_DIR, exist_ok=True)
                fp = os.path.join(UPLOAD_DIR, name)
                if os.path.exists(fp):
                    base, ext = os.path.splitext(name)
                    fp = os.path.join(UPLOAD_DIR, f"{base}_{int(time.time())}{ext}")
                with open(fp, "wb") as f:
                    f.write(data)
                self._send(200, "Success")
                print(f"Received: {name}")
            except Exception as e:
                print("Error:", e)
                self._send(500, "error")


def _gen_cert_cryptography(cert_path, key_path, ip):
    """Generate a self-signed cert with the `cryptography` lib (cross-platform, no openssl needed)."""
    import ipaddress
    from datetime import datetime, timedelta, timezone

    from cryptography import x509
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import rsa
    from cryptography.x509.oid import NameOID

    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, ip)])
    alt = [x509.DNSName("localhost")]
    try:
        alt.append(x509.IPAddress(ipaddress.ip_address(ip)))
    except ValueError:
        pass
    cert = (
        x509.CertificateBuilder()
        .subject_name(name).issuer_name(name)
        .public_key(key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
        .not_valid_after(datetime.now(timezone.utc) + timedelta(days=825))
        .add_extension(x509.SubjectAlternativeName(alt), critical=False)
        .sign(key, hashes.SHA256())
    )
    with open(key_path, "wb") as f:
        f.write(key.private_bytes(serialization.Encoding.PEM,
                                  serialization.PrivateFormat.TraditionalOpenSSL,
                                  serialization.NoEncryption()))
    with open(cert_path, "wb") as f:
        f.write(cert.public_bytes(serialization.Encoding.PEM))


def _gen_cert_openssl(cert_path, key_path, ip):
    subprocess.run(
        ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
         "-keyout", key_path, "-out", cert_path, "-days", "825",
         "-subj", f"/CN={ip}", "-addext", f"subjectAltName=IP:{ip},DNS:localhost"],
        check=True,
    )


def ensure_cert(cert_path, key_path, ip):
    """Make a self-signed cert if one isn't present yet (via cryptography, else openssl)."""
    if os.path.exists(cert_path) and os.path.exists(key_path):
        print(f"[tls] using existing cert: {cert_path}")
        return
    print(f"[tls] generating self-signed cert for {ip} -> {cert_path}")
    try:
        _gen_cert_cryptography(cert_path, key_path, ip)
        return
    except ImportError:
        pass
    if shutil.which("openssl"):
        _gen_cert_openssl(cert_path, key_path, ip)
        return
    sys.exit(
        "[tls] can't generate a cert. Do ONE of:\n"
        "  - pip install cryptography    (then re-run)\n"
        "  - install openssl             (then re-run)\n"
        "  - bring your own:  --cert cert.pem --key key.pem\n"
        f"  manual: openssl req -x509 -newkey rsa:2048 -nodes -keyout {key_path} "
        f"-out {cert_path} -days 825 -subj /CN={ip}"
    )


def run():
    global SCHEME, PORT
    ap = argparse.ArgumentParser(
        description="PhotoDrop (hardened): offline LAN photo drop, access-token gated.")
    ap.add_argument("--https", action="store_true",
                    help="serve over TLS with a self-signed cert (auto-generated on first run)")
    ap.add_argument("--port", type=int, default=PORT, help=f"port (default {PORT})")
    ap.add_argument("--cert", default="cert.pem", help="TLS cert path (with --https)")
    ap.add_argument("--key", default="key.pem", help="TLS key path (with --https)")
    args = ap.parse_args()
    PORT = args.port

    os.makedirs(UPLOAD_DIR, exist_ok=True)
    ip = os.environ.get("PHOTODROP_HOST") or get_local_ip()
    httpd = socketserver.TCPServer(("0.0.0.0", PORT), Handler)

    if args.https:
        ensure_cert(args.cert, args.key, ip)
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        ctx.load_cert_chain(args.cert, args.key)
        httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
        SCHEME = "https"

    url = f"{SCHEME}://{ip}:{PORT}/?token={TOKEN}"
    tls_tag = " + TLS" if args.https else ""
    print("\n" + "=" * 52)
    print(f" PHOTODROP (hardened: offline + token{tls_tag}) STARTED")
    print("=" * 52)
    print(f" Desktop URL : {url}")
    print(" (open this — the token gates ALL access; the QR carries it to phones)")
    if args.https:
        print(" (self-signed TLS: your phone shows a one-time 'not private' warning — tap through)")
    print(f" Photos      : {os.path.abspath(UPLOAD_DIR)}")
    print("=" * 52 + "\n")
    webbrowser.open(url)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nStopping server...")
        httpd.server_close()


if __name__ == "__main__":
    run()
