timetracker/scripts/serve/main.go
2026-08-03 21:51:48 +02:00

572 lines
20 KiB
Go

// serve — Timetrack HTTP server
// Portable Go HTTP server for WLAN APK distribution and Flutter Web serving.
// No external dependencies — standard library only.
//
// Usage:
//
// ./serve [--dir build/apk] [--port 8888] [--debug] # APK mode (default)
// ./serve --web [--dir build/web] [--port 8080] [--debug] # Web mode
package main
import (
"encoding/json"
"flag"
"fmt"
"html/template"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// ── HTML template ─────────────────────────────────────────────────────────────
const htmlTpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timetrack — Android App</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0F172A;
color: #E2E8F0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: #1E293B;
border-radius: 20px;
padding: 36px 32px;
max-width: 480px;
width: 100%;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.logo-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
.logo-icon {
width: 56px; height: 56px;
background: #2563EB;
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 28px; flex-shrink: 0;
}
h1 { font-size: 28px; font-weight: 700; color: #93C5FD; }
.tagline { font-size: 14px; color: #94A3B8; margin-bottom: 28px; margin-top: 4px; }
.meta-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 12px; margin-bottom: 28px;
}
.meta-item { background: #0F172A; border-radius: 10px; padding: 12px 14px; }
.meta-label { font-size: 11px; color: #64748B; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.meta-value { font-size: 15px; font-weight: 600; color: #E2E8F0; }
.download-btn {
display: block; width: 100%; padding: 16px;
background: #2563EB; color: white;
text-align: center; text-decoration: none;
border-radius: 12px; font-size: 17px; font-weight: 600;
letter-spacing: 0.3px; transition: background 0.2s; margin-bottom: 20px;
}
.download-btn:hover, .download-btn:active { background: #1D4ED8; }
.download-btn .icon { margin-right: 8px; }
.sha-section { background: #0F172A; border-radius: 12px; padding: 16px; margin-bottom: 20px; }
.sha-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
font-size: 13px; font-weight: 600; color: #64748B;
text-transform: uppercase; letter-spacing: 0.5px;
}
.sha-expected {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 11px; color: #93C5FD; word-break: break-all;
line-height: 1.6; margin-bottom: 12px;
}
.sha-check-area { display: none; }
.sha-check-area.visible { display: block; }
.file-input-label {
display: block; border: 2px dashed #2563EB; border-radius: 8px;
padding: 14px; text-align: center; font-size: 13px; color: #64748B;
cursor: pointer; transition: border-color 0.2s, color 0.2s; margin-bottom: 10px;
}
.file-input-label:hover { border-color: #60A5FA; color: #E2E8F0; }
#fileInput { display: none; }
.progress-bar-wrap { background: #1E293B; border-radius: 6px; height: 6px; margin-bottom: 10px; display: none; }
.progress-bar { height: 100%; background: #2563EB; border-radius: 6px; width: 0%; transition: width 0.1s; }
.sha-result {
border-radius: 8px; padding: 12px 14px; font-size: 13px; font-weight: 600;
display: none; align-items: center; gap: 8px;
}
.sha-result.match { background: #14532D; color: #86EFAC; border: 1px solid #16A34A; }
.sha-result.mismatch { background: #7F1D1D; color: #FCA5A5; border: 1px solid #DC2626; }
.sha-computed { font-family: "SF Mono", "Fira Code", monospace; font-size: 10px; color: #64748B; word-break: break-all; margin-top: 6px; display: none; }
.toggle-verify {
background: none; border: 1px solid #2563EB; color: #64748B;
border-radius: 8px; padding: 8px 14px; font-size: 12px;
cursor: pointer; width: 100%; transition: background 0.2s;
}
.toggle-verify:hover { background: #0F172A; color: #E2E8F0; }
.install-hint { font-size: 12px; color: #475569; text-align: center; margin-bottom: 16px; line-height: 1.5; }
.install-hint strong { color: #64748B; }
.footer { text-align: center; font-size: 11px; color: #334155; margin-top: 20px; }
</style>
</head>
<body>
<div class="card">
<div class="logo-row">
<div class="logo-icon">⏱</div>
<h1>Timetrack</h1>
</div>
<p class="tagline">Time tracking — offline-first, cross-platform</p>
<div class="meta-grid">
<div class="meta-item">
<div class="meta-label">Version</div>
<div class="meta-value">{{.Version}}</div>
</div>
<div class="meta-item">
<div class="meta-label">Größe</div>
<div class="meta-value">{{.SizeMB}} MB</div>
</div>
<div class="meta-item">
<div class="meta-label">Build</div>
<div class="meta-value">{{.BuildDate}}</div>
</div>
<div class="meta-item">
<div class="meta-label">Plattform</div>
<div class="meta-value">Android</div>
</div>
</div>
<a class="download-btn" href="/download">
<span class="icon">⬇</span> APK herunterladen
</a>
<p class="install-hint">
Nach dem Download: Datei öffnen → Installieren.<br>
<strong>Einmalig:</strong> Einstellungen → Sicherheit →<br>
Browser/Dateimanager als Installationsquelle erlauben.
</p>
<div class="sha-section">
<div class="sha-header"><span>🔒</span> SHA256-Prüfsumme</div>
<div class="sha-expected" id="expectedHash">{{.SHA256}}</div>
<button class="toggle-verify" onclick="toggleVerify()">Heruntergeladene Datei prüfen</button>
<div class="sha-check-area" id="verifyArea">
<br>
<label class="file-input-label" for="fileInput">📁 APK-Datei auswählen zum Vergleichen</label>
<input type="file" id="fileInput" accept=".apk" onchange="verifyFile(this)">
<div class="progress-bar-wrap" id="progressWrap"><div class="progress-bar" id="progressBar"></div></div>
<div class="sha-result" id="shaResult"><span id="shaIcon"></span><span id="shaText"></span></div>
<div class="sha-computed" id="shaComputed"></div>
</div>
</div>
<div class="footer">Timetrack · Lokaler Testserver · {{.Host}}</div>
</div>
<script>
function toggleVerify() {
document.getElementById('verifyArea').classList.toggle('visible');
}
async function verifyFile(input) {
const file = input.files[0];
if (!file) return;
const expected = document.getElementById('expectedHash').textContent.trim().toLowerCase();
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const result = document.getElementById('shaResult');
const computed = document.getElementById('shaComputed');
result.style.display = 'none'; computed.style.display = 'none';
progressWrap.style.display = 'block'; progressBar.style.width = '0%';
const CHUNK = 4 * 1024 * 1024;
let offset = 0; const parts = [];
while (offset < file.size) {
const buf = await file.slice(offset, offset + CHUNK).arrayBuffer();
parts.push(new Uint8Array(buf));
offset += CHUNK;
progressBar.style.width = Math.min(100, Math.round(offset / file.size * 100)) + '%';
}
const total = parts.reduce((s, p) => s + p.length, 0);
const merged = new Uint8Array(total);
let pos = 0; for (const p of parts) { merged.set(p, pos); pos += p.length; }
const hashBuf = await crypto.subtle.digest('SHA-256', merged);
const actual = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
progressWrap.style.display = 'none'; result.style.display = 'flex';
if (actual === expected) {
result.className = 'sha-result match';
document.getElementById('shaIcon').textContent = '✓';
document.getElementById('shaText').textContent = 'Prüfsumme stimmt überein — Datei ist unverändert.';
} else {
result.className = 'sha-result mismatch';
document.getElementById('shaIcon').textContent = '✗';
document.getElementById('shaText').textContent = 'Prüfsummen stimmen NICHT überein!';
computed.textContent = 'Berechnet: ' + actual; computed.style.display = 'block';
}
}
</script>
</body>
</html>`
// ── Data structures ───────────────────────────────────────────────────────────
type pageData struct {
Version string
SizeMB string
BuildDate string
SHA256 string
Host string
}
type server struct {
apkPath string
sha256 string
sizeMB string
date string
version string
etag string
debug bool
tpl *template.Template
}
// ── APK discovery ─────────────────────────────────────────────────────────────
// apkVersion parses version info from filenames like timetrack-0.1.0+3.apk
// Returns the full version string (e.g. "0.1.0+3") or the filename stem.
var apkRe = regexp.MustCompile(`timetrack-([0-9]+\.[0-9]+\.[0-9]+\+[0-9]+)\.apk$`)
func parseVersion(name string) string {
m := apkRe.FindStringSubmatch(name)
if len(m) == 2 {
return m[1]
}
return strings.TrimSuffix(name, ".apk")
}
// findLatestAPK returns the path to the newest APK in dir.
// Prefers timetrack.apk symlink, falls back to newest versioned file.
func findLatestAPK(dir string) (string, error) {
symlink := filepath.Join(dir, "timetrack.apk")
if _, err := os.Stat(symlink); err == nil {
resolved, err := filepath.EvalSymlinks(symlink)
if err == nil {
return resolved, nil
}
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("cannot read dir %s: %w", dir, err)
}
var apks []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".apk") && e.Name() != "timetrack.apk" {
apks = append(apks, e.Name())
}
}
if len(apks) == 0 {
return "", fmt.Errorf("no .apk files found in %s", dir)
}
sort.Strings(apks)
return filepath.Join(dir, apks[len(apks)-1]), nil
}
// ── SHA256 sidecar ────────────────────────────────────────────────────────────
func readSHA256(apkPath string) string {
data, err := os.ReadFile(apkPath + ".sha256")
if err != nil {
return ""
}
fields := strings.Fields(string(data))
if len(fields) > 0 {
return fields[0]
}
return ""
}
// ── Local IP ──────────────────────────────────────────────────────────────────
func localIP() string {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return "127.0.0.1"
}
defer conn.Close()
return conn.LocalAddr().(*net.UDPAddr).IP.String()
}
// ── QR code ───────────────────────────────────────────────────────────────────
func printQR(url string) {
if _, err := exec.LookPath("qrencode"); err != nil {
fmt.Println(" (qrencode nicht verfügbar — URL manuell eingeben)")
return
}
cmd := exec.Command("qrencode", "-t", "UTF8", url)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
// ── HTTP handlers ─────────────────────────────────────────────────────────────
func (s *server) noCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("ETag", s.etag)
next.ServeHTTP(w, r)
})
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
host := r.Host
if host == "" {
host = "localhost"
}
data := pageData{
Version: s.version,
SizeMB: s.sizeMB,
BuildDate: s.date,
SHA256: s.sha256,
Host: host,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tpl.Execute(w, data); err != nil && s.debug {
fmt.Fprintf(os.Stderr, "template error: %v\n", err)
}
}
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
f, err := os.Open(s.apkPath)
if err != nil {
http.Error(w, "APK not found", http.StatusNotFound)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
http.Error(w, "stat error", http.StatusInternalServerError)
return
}
name := filepath.Base(s.apkPath)
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
w.Header().Set("Accept-Ranges", "bytes")
buf := make([]byte, 256*1024)
io.CopyBuffer(w, f, buf) //nolint:errcheck
}
func (s *server) handleSHA256(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck
"sha256": s.sha256,
"filename": filepath.Base(s.apkPath),
})
}
// ── Main ──────────────────────────────────────────────────────────────────────
func main() {
dir := flag.String("dir", "", "Verzeichnis mit den Dateien (Standard: build/apk oder build/web)")
port := flag.Int("port", 8888, "HTTP-Port")
debug := flag.Bool("debug", false, "Ausführliche Request-Logs aktivieren")
web := flag.Bool("web", false, "Web-Modus: Flutter-Web-App als Static Files ausliefern")
flag.Parse()
if *web {
runWebMode(*dir, *port, *debug)
} else {
runAPKMode(*dir, *port, *debug)
}
}
// ── Web mode ──────────────────────────────────────────────────────────────────
func runWebMode(dir string, port int, debug bool) {
if dir == "" {
dir = "build/web"
}
absDir, err := filepath.Abs(dir)
if err != nil || !dirExists(absDir) {
fatalf("FEHLER: Verzeichnis '%s' nicht gefunden. Zuerst 'make build_web' ausführen.\n", dir)
}
ip := localIP()
addr := fmt.Sprintf("0.0.0.0:%d", port)
url := fmt.Sprintf("http://%s:%d", ip, port)
printWebBanner(url, absDir, debug)
printQR(url)
printWebFooter()
var handler http.Handler = http.FileServer(http.Dir(absDir))
if debug {
handler = logMiddleware(handler)
}
if err := http.ListenAndServe(addr, handler); err != nil {
fatalf("Server-Fehler: %v\n", err)
}
}
// ── APK mode ──────────────────────────────────────────────────────────────────
func runAPKMode(dir string, port int, debug bool) {
if dir == "" {
dir = "build/apk"
}
absDir, err := filepath.Abs(dir)
if err != nil || !dirExists(absDir) {
fatalf("FEHLER: Verzeichnis '%s' nicht gefunden. Zuerst 'make build_android' ausführen.\n", dir)
}
apkPath, err := findLatestAPK(absDir)
if err != nil {
fatalf("FEHLER: %v\n → Zuerst 'make build_android' ausführen.\n", err)
}
info, _ := os.Stat(apkPath)
sizeMB := fmt.Sprintf("%.1f", float64(info.Size())/(1024*1024))
buildDate := time.Unix(info.ModTime().Unix(), 0).Format("02.01.2006")
sha256 := readSHA256(apkPath)
version := parseVersion(filepath.Base(apkPath))
etag := fmt.Sprintf(`"%d"`, info.ModTime().Unix())
tpl, err := template.New("page").Parse(htmlTpl)
if err != nil {
fatalf("FEHLER: Template-Fehler: %v\n", err)
}
srv := &server{
apkPath: apkPath,
sha256: sha256,
sizeMB: sizeMB,
date: buildDate,
version: version,
etag: etag,
debug: debug,
tpl: tpl,
}
ip := localIP()
addr := fmt.Sprintf("0.0.0.0:%d", port)
url := fmt.Sprintf("http://%s:%d", ip, port)
printBanner(filepath.Base(apkPath), float64(info.Size())/(1024*1024), url, sha256, debug)
printQR(url)
printFooter()
mux := http.NewServeMux()
mux.HandleFunc("/", srv.handleIndex)
mux.HandleFunc("/download", srv.handleDownload)
mux.HandleFunc("/sha256", srv.handleSHA256)
var handler http.Handler = srv.noCache(mux)
if debug {
handler = logMiddleware(handler)
}
if err := http.ListenAndServe(addr, handler); err != nil {
fatalf("Server-Fehler: %v\n", err)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format, args...)
os.Exit(1)
}
func printBanner(apkName string, sizeMB float64, url, sha256 string, dbg bool) {
sep := strings.Repeat("═", 50)
fmt.Println()
fmt.Println(sep)
fmt.Println(" Timetrack APK — WLAN-Download")
fmt.Println(sep)
fmt.Printf(" Datei : %s (%.1f MB)\n", apkName, sizeMB)
fmt.Printf(" URL : %s\n", url)
if dbg {
fmt.Println(" Modus : DEBUG (ausführliche Request-Logs)")
}
if sha256 != "" {
prefix := sha256
if len(prefix) > 16 {
prefix = prefix[:16]
}
fmt.Printf(" SHA256: %s…\n", prefix)
} else {
fmt.Println(" SHA256: keine .sha256-Datei gefunden")
}
fmt.Println()
fmt.Println(" QR-Code scannen:")
fmt.Println()
}
func printFooter() {
sep := strings.Repeat("═", 50)
fmt.Println(sep)
fmt.Println(" Handy: QR scannen → Seite öffnen → Herunterladen")
fmt.Println(" SHA256-Prüfung direkt im Browser verfügbar")
fmt.Println(" Stoppen mit Ctrl+C")
fmt.Println(sep)
fmt.Println()
}
func logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
fmt.Printf("[%s] %s %s %s\n",
time.Now().Format("15:04:05"),
r.Method, r.URL.Path,
time.Since(start).Round(time.Millisecond),
)
})
}
func printWebBanner(url, dir string, dbg bool) {
sep := strings.Repeat("═", 50)
fmt.Println()
fmt.Println(sep)
fmt.Println(" Timetrack Web — WLAN-Zugriff")
fmt.Println(sep)
fmt.Printf(" Verzeichnis : %s\n", dir)
fmt.Printf(" URL : %s\n", url)
if dbg {
fmt.Println(" Modus : DEBUG (ausführliche Request-Logs)")
}
fmt.Println()
fmt.Println(" QR-Code scannen:")
fmt.Println()
}
func printWebFooter() {
sep := strings.Repeat("═", 50)
fmt.Println(sep)
fmt.Println(" Handy: QR scannen → App direkt im Browser öffnen")
fmt.Println(" Stoppen mit Ctrl+C")
fmt.Println(sep)
fmt.Println()
}