init
Some checks failed
release-tag / release-image (push) Failing after 38s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
Some checks failed
release-tag / release-image (push) Failing after 38s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
This commit is contained in:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -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 }}
|
||||||
124
.gitea/workflows/release.yml
Normal file
124
.gitea/workflows/release.yml
Normal file
@@ -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
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -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"]
|
||||||
8
apps.json
Normal file
8
apps.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.send.nrw/sendnrw/virtual-appstore
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
274
main.go
Normal file
274
main.go
Normal file
@@ -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.")
|
||||||
|
}
|
||||||
134
static/styles.css
Normal file
134
static/styles.css
Normal file
@@ -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%; }
|
||||||
|
}
|
||||||
79
templates/index.html
Normal file
79
templates/index.html
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{{- /* templates/index.html */ -}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>{{ .Title }}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="container">
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="search" type="search" placeholder="Suchen…" aria-label="Suchen" />
|
||||||
|
<select id="category">
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
{{- range .Categories }}
|
||||||
|
<option value="{{ . }}">{{ . }}</option>
|
||||||
|
{{- end }}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<div id="grid" class="grid">
|
||||||
|
{{- range .Apps }}
|
||||||
|
<a class="tile" href="{{ .URL | safeURL }}" target="_blank" rel="noopener noreferrer"
|
||||||
|
data-title="{{ .Title }}" data-category="{{ .Category }}">
|
||||||
|
<div class="tile-inner" style="{{ if .Color }}--tile-color: {{ .Color }};{{ end }}">
|
||||||
|
<div class="icon" aria-hidden="true">
|
||||||
|
{{- /* Falls Icon eine URL ist, Bild anzeigen. Sonst Emoji/Text. */ -}}
|
||||||
|
{{- if or (hasPrefix .Icon "http://") (hasPrefix .Icon "https://") -}}
|
||||||
|
<img src="{{ .Icon }}" alt="" loading="lazy" />
|
||||||
|
{{- else -}}
|
||||||
|
<span>{{ .Icon }}</span>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
<div class="title">{{ .Title }}</div>
|
||||||
|
{{- if .Category }}<div class="badge">{{ .Category }}</div>{{ end }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{- end }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="container footer">
|
||||||
|
<small>Stand: {{ .Now.Format "02.01.2006 15:04" }}</small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// kleine Hilfsfunktionen
|
||||||
|
const el = (sel) => document.querySelector(sel);
|
||||||
|
const els = (sel) => Array.from(document.querySelectorAll(sel));
|
||||||
|
const norm = (s) => (s || "").toLowerCase().normalize("NFKD");
|
||||||
|
|
||||||
|
// Suche & Kategorie-Filter
|
||||||
|
const search = el('#search');
|
||||||
|
const cat = el('#category');
|
||||||
|
const tiles = els('.tile');
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const q = norm(search.value);
|
||||||
|
const c = cat.value;
|
||||||
|
tiles.forEach(t => {
|
||||||
|
const title = norm(t.dataset.title);
|
||||||
|
const category = t.dataset.category || "";
|
||||||
|
const matchQ = !q || title.includes(q);
|
||||||
|
const matchC = !c || category === c;
|
||||||
|
t.style.display = (matchQ && matchC) ? "" : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
search.addEventListener('input', applyFilter);
|
||||||
|
cat.addEventListener('change', applyFilter);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user