From 0bb37614efd5843973d238d2c3f6b4651b15be0a Mon Sep 17 00:00:00 2001 From: jbergner Date: Sat, 6 Sep 2025 20:31:05 +0200 Subject: [PATCH] init --- .gitea/workflows/registry.yml | 51 +++++++ .gitea/workflows/release.yml | 124 +++++++++++++++ Dockerfile | 26 ++++ apps.json | 8 + go.mod | 3 + main.go | 274 ++++++++++++++++++++++++++++++++++ static/styles.css | 134 +++++++++++++++++ templates/index.html | 79 ++++++++++ 8 files changed, 699 insertions(+) create mode 100644 .gitea/workflows/registry.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 Dockerfile create mode 100644 apps.json create mode 100644 go.mod create mode 100644 main.go create mode 100644 static/styles.css create mode 100644 templates/index.html diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..37f59c1 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,124 @@ +# Git(tea) Actions workflow: Build and publish standalone binaries **plus** bundled `static/` assets +# ──────────────────────────────────────────────────────────────────── +# ✧ Builds the Go‑based WoL server for four targets **and** packt das Verzeichnis +# `static` zusammen mit der Binary, sodass es relativ zur ausführbaren Datei +# liegt (wichtig für die eingebauten Bootstrap‑Assets & favicon). +# +# • linux/amd64 → wol-server-linux-amd64.tar.gz +# • linux/arm64 → wol-server-linux-arm64.tar.gz +# • linux/arm/v7 → wol-server-linux-armv7.tar.gz +# • windows/amd64 → wol-server-windows-amd64.zip +# +# ✧ Artefakte landen im Workflow und – bei Tag‑Push (vX.Y.Z) – als Release‑Assets. +# +# Secrets/variables: +# GITEA_TOKEN – optional, falls default token keine Release‑Rechte hat. +# ──────────────────────────────────────────────────────────────────── + +name: build-binaries + +on: + push: + branches: [ "main" ] + tags: [ "v*" ] + +jobs: + build: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-fast + + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + ext: "" + - goos: linux + goarch: arm64 + ext: "" + - goos: linux + goarch: arm + goarm: "7" + ext: "" + - goos: windows + goarch: amd64 + ext: ".exe" + + env: + GO_VERSION: "1.24" + BINARY_NAME: virtual-appstore + + steps: + - name: Checkout source + uses: actions/checkout@v3 + + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Build ${{ matrix.goos }}/${{ matrix.goarch }}${{ matrix.goarm && format('/v{0}', matrix.goarm) || '' }} + shell: bash + run: | + set -e + mkdir -p dist/package + if [ -n "${{ matrix.goarm }}" ]; then export GOARM=${{ matrix.goarm }}; fi + CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -trimpath -ldflags "-s -w" \ + -o "dist/package/${BINARY_NAME}${{ matrix.ext }}" . + # Assets: statisches Verzeichnis beilegen + cp -r static dist/package/ + + - name: Package archive with static assets + shell: bash + run: | + set -e + cd dist + if [ "${{ matrix.goos }}" == "windows" ]; then + ZIP_NAME="${BINARY_NAME}-windows-amd64.zip" + (cd package && zip -r "../$ZIP_NAME" .) + else + ARCH_SUFFIX="${{ matrix.goarch }}" + if [ "${{ matrix.goarch }}" == "arm" ]; then ARCH_SUFFIX="armv${{ matrix.goarm }}"; fi + TAR_NAME="${BINARY_NAME}-${{ matrix.goos }}-${ARCH_SUFFIX}.tar.gz" + tar -czf "$TAR_NAME" -C package . + fi + + - name: Upload workflow artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }} + path: dist/*.tar.gz + if-no-files-found: ignore + - uses: actions/upload-artifact@v3 + with: + name: windows-amd64 + path: dist/*.zip + if-no-files-found: ignore + + # Release Schritt für Tag‑Pushes + release: + if: startsWith(github.ref, 'refs/tags/') + needs: build + runs-on: ubuntu-fast + permissions: + contents: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + path: ./dist + + - name: Create / Update release + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }} + with: + name: "Release ${{ github.ref_name }}" + tag_name: ${{ github.ref_name }} + draft: false + prerelease: false + files: | + dist/**/virtual-appstore-*.tar.gz + dist/**/virtual-appstore-*.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1528fb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod go.sum* . 2>/dev/null || true +RUN go mod init appstore || true +COPY . . +RUN go build -o /bin/app + +FROM alpine:3.22 +WORKDIR /app +COPY --from=build /bin/app /app/app +COPY apps.json /app/apps.json +COPY templates /app/templates +COPY static /app/static +ENV SERVER_MODE="https" \ + ADDR=":8443" \ + APP_TITLE="Mein App-Store" \ + APPS_JSON="/app/apps.json" \ + STATIC_DIR="/app/static" \ + TEMPLATE_DIR="/app/templates" \ + TLS_CERT_FILE="/app/pub.pem" \ + TLS_KEY_FILE="/app/pri.pem" \ + HSTS="true" \ + HTTP_REDIRECT_ADDR=":8080" \ + HTTP_REDIRECT_ENABLED="true" +EXPOSE 8080,8443 +CMD ["/app/app"] diff --git a/apps.json b/apps.json new file mode 100644 index 0000000..767b24a --- /dev/null +++ b/apps.json @@ -0,0 +1,8 @@ +[ + { "title": "Docs", "url": "https://docs.google.com", "icon": "📄", "category": "Produktivität", "color": "#0ea5e9" }, + { "title": "GitHub", "url": "https://github.com", "icon": "🐙", "category": "Entwicklung", "color": "#111827" }, + { "title": "Stack Overflow", "url": "https://stackoverflow.com", "icon": "💡", "category": "Entwicklung" }, + { "title": "Figma", "url": "https://www.figma.com", "icon": "🎨", "category": "Design", "color": "#22c55e" }, + { "title": "Jira", "url": "https://jira.atlassian.com", "icon": "📋", "category": "Produktivität" }, + { "title": "Notion", "url": "https://www.notion.so", "icon": "🧱", "category": "Produktivität" } +] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a8a811a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/virtual-appstore + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..88e09e7 --- /dev/null +++ b/main.go @@ -0,0 +1,274 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "html/template" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "sort" + "strings" + "syscall" + "time" +) + +type App struct { + Title string `json:"title"` + URL string `json:"url"` + Icon string `json:"icon"` + Category string `json:"category"` + Color string `json:"color"` +} + +type PageData struct { + Title string + Apps []App + Categories []string + Now time.Time +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} + +func loadApps(path string) ([]App, []string, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + var apps []App + if err := json.Unmarshal(b, &apps); err != nil { + return nil, nil, err + } + sort.Slice(apps, func(i, j int) bool { + return strings.ToLower(apps[i].Title) < strings.ToLower(apps[j].Title) + }) + seen := map[string]bool{} + var cats []string + for _, a := range apps { + if a.Category == "" { + continue + } + if !seen[a.Category] { + seen[a.Category] = true + cats = append(cats, a.Category) + } + } + sort.Strings(cats) + return apps, cats, nil +} + +func withSecurityHeaders(next http.Handler, hsts bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("X-XSS-Protection", "0") + w.Header().Set("Content-Security-Policy", + "default-src 'self'; img-src 'self' data: https:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self';") + + if hsts && r.TLS != nil { + w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +} + +func redirectToHTTPS(httpsAddr string) http.Handler { + hostPort := func(host string) string { + h, _, err := net.SplitHostPort(host) + if err != nil { + return host + } + return h + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + httpsPort := ":8443" + if httpsAddr != "" && httpsAddr != ":8443" { + httpsPort = httpsAddr + } + targetHost := hostPort(host) + if _, _, err := net.SplitHostPort(host); err == nil { + targetHost = hostPort(host) + httpsPort + } else { + if httpsPort != ":8443" { + targetHost = host + httpsPort + } + } + target := "https://" + targetHost + r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusMovedPermanently) + }) +} + +func main() { + // App-Config + appTitle := getenv("APP_TITLE", "Mein App-Store") + jsonPath := getenv("APPS_JSON", "apps.json") + staticDir := getenv("STATIC_DIR", "static") + tmplDir := getenv("TEMPLATE_DIR", "templates") + + // Server-Modus + serverMode := strings.ToLower(getenv("SERVER_MODE", "http")) // "http" | "https" + addr := getenv("ADDR", func() string { + if serverMode == "https" { + return ":8443" + } + return ":8080" + }()) + + // HTTPS-spezifisch + certFile := getenv("TLS_CERT_FILE", "") + keyFile := getenv("TLS_KEY_FILE", "") + hstsEnabled := getenv("HSTS", "true") == "true" + + // Optionaler Redirect (nur wenn SERVER_MODE=https) + redirectAddr := getenv("HTTP_REDIRECT_ADDR", ":8080") + redirectEnabled := getenv("HTTP_REDIRECT_ENABLED", "true") == "true" + + // Templates + tmpl := template.Must(template.New("index.html").Funcs(template.FuncMap{ + "safeURL": func(u string) template.URL { return template.URL(u) }, + "hasPrefix": strings.HasPrefix, + }).ParseFiles(filepath.Join(tmplDir, "index.html"))) + + readData := func() (PageData, error) { + apps, cats, err := loadApps(jsonPath) + if err != nil { + return PageData{}, err + } + return PageData{ + Title: appTitle, + Apps: apps, + Categories: cats, + Now: time.Now(), + }, nil + } + + // Handler + mux := http.NewServeMux() + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + data, err := readData() + if err != nil { + http.Error(w, "Fehler beim Laden der Apps: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil { + log.Println("template error:", err) + } + }) + + // Gemeinsame Server-Instanzen + var mainSrv *http.Server + var redirSrv *http.Server + + // Graceful Shutdown Verkabelung + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + switch serverMode { + case "https": + // HTTPS mit Security-Headern (inkl. HSTS) + secureHandler := withSecurityHeaders(mux, hstsEnabled) + + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + PreferServerCipherSuites: true, + CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384}, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + }, + } + + if certFile == "" || keyFile == "" { + log.Fatal("SERVER_MODE=https, aber TLS_CERT_FILE oder TLS_KEY_FILE fehlt.") + } + + mainSrv = &http.Server{ + Addr: addr, + Handler: secureHandler, + TLSConfig: tlsCfg, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Optionaler HTTP→HTTPS Redirect + if redirectEnabled && redirectAddr != "" { + redirSrv = &http.Server{ + Addr: redirectAddr, + Handler: redirectToHTTPS(addr), + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 10 * time.Second, + } + go func() { + log.Printf("HTTP Redirect auf %s → HTTPS %s …", redirectAddr, addr) + if err := redirSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("Redirect-Server Fehler: %v", err) + } + }() + } + + go func() { + log.Printf("HTTPS läuft auf %s …", addr) + if err := mainSrv.ListenAndServeTLS(certFile, keyFile); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTPS-Server Fehler: %v", err) + } + }() + + case "http": + // HTTP ohne HSTS (Security-Header bleiben) + handler := withSecurityHeaders(mux, false) + mainSrv = &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + log.Printf("HTTP läuft auf %s …", addr) + if err := mainSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP-Server Fehler: %v", err) + } + }() + + default: + log.Fatalf("Ungültiger SERVER_MODE: %q (erwartet: http oder https)", serverMode) + } + + // Warten auf Stop-Signal + <-stop + log.Println("Beende …") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if redirSrv != nil { + _ = redirSrv.Shutdown(ctx) + } + if mainSrv != nil { + _ = mainSrv.Shutdown(ctx) + } + log.Println("Sauber beendet.") +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..a6ae495 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,134 @@ +:root { + --bg: #0b1020; + --card: #111827; + --text: #e5e7eb; + --muted: #9ca3af; + --accent: #3b82f6; + --ring: rgba(59,130,246,.35); +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; + background: radial-gradient(1200px 800px at 10% -10%, #1f2937, transparent), var(--bg); + color: var(--text); +} + +.container { + max-width: 1100px; + padding: 24px; + margin: 0 auto; +} + +header.container { + display: grid; + grid-template-columns: 1fr auto; + gap: 16px; + align-items: end; +} +header h1 { + margin: 0; + font-size: 28px; + letter-spacing: .2px; +} +.controls { + display: flex; + gap: 8px; +} +.controls input[type="search"], .controls select { + background: #0f172a; + color: var(--text); + border: 1px solid #1f2937; + border-radius: 12px; + padding: 10px 12px; + outline: none; +} +.controls input[type="search"]:focus, .controls select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--ring); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 14px; + margin-top: 8px; +} + +.tile { + text-decoration: none; + color: inherit; + transform: translateZ(0); +} +.tile-inner { + background: linear-gradient(0deg, rgba(255,255,255,.02), rgba(255,255,255,.02)), var(--card); + border: 1px solid rgba(255,255,255,.06); + border-radius: 16px; + padding: 16px; + min-height: 120px; + display: grid; + grid-template-rows: 1fr auto auto; + gap: 8px; + transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease; + box-shadow: 0 6px 20px rgba(0,0,0,.25); + position: relative; +} +.tile-inner::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 16px; + background: radial-gradient(200px 120px at 20% 0%, color-mix(in oklab, var(--tile-color, var(--accent)) 35%, transparent), transparent); + opacity: .25; + pointer-events: none; +} +.tile:hover .title { text-decoration: underline; } +.tile:hover .tile-inner { + transform: translateY(-2px); + border-color: rgba(255,255,255,.18); + box-shadow: 0 10px 26px rgba(0,0,0,.35); +} + +.icon { + display: grid; + place-items: center; + align-self: start; + font-size: 36px; + width: 44px; + height: 44px; + border-radius: 12px; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.06); +} +.icon img { + max-width: 100%; + max-height: 100%; + border-radius: 8px; +} + +.title { + font-weight: 700; + font-size: 16px; + line-height: 1.2; +} +.badge { + display: inline-block; + font-size: 12px; + color: var(--muted); + border: 1px solid rgba(255,255,255,.1); + border-radius: 999px; + padding: 4px 8px; + width: max-content; +} + +.footer { + opacity: .6; + text-align: center; +} +@media (max-width: 520px) { + header.container { grid-template-columns: 1fr; } + .controls { width: 100%; } + .controls input[type="search"] { flex: 1; width: 100%; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6d6c379 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,79 @@ +{{- /* templates/index.html */ -}} + + + + + + {{ .Title }} + + + + + +
+

{{ .Title }}

+
+ + +
+
+ +
+ +
+ + + + + +