From 6d30537825f4844959020b612bf7235158e1a3ce Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 19 May 2026 06:31:43 +0200 Subject: [PATCH] init --- .dockerignore | 6 + Dockerfile | 32 ++ README.md | 143 ++++++++- docker-compose.yml | 28 ++ go.mod | 21 ++ go.sum | 49 +++ main.go | 732 ++++++++++++++++++++++++++++++++++++++++++ web/assets/app.js | 218 +++++++++++++ web/assets/styles.css | 245 ++++++++++++++ web/index.html | 117 +++++++ 10 files changed, 1590 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 web/assets/app.js create mode 100644 web/assets/styles.css create mode 100644 web/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8de5992 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +README.md +shortener.db +data +*.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de96aa4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.22-alpine AS build + +WORKDIR /app + +COPY go.mod ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /url-shortener ./main.go + +FROM alpine:3.20 + +RUN adduser -D -H appuser \ + && mkdir -p /data \ + && chown -R appuser:appuser /data + +USER appuser + +COPY --from=build /url-shortener /usr/local/bin/url-shortener + +EXPOSE 8080 + +ENV ADDR=:8080 +ENV DB_PATH=/data/shortener.db +ENV BASE_URL=http://localhost:8080 +ENV AUTH_MODE=local + +VOLUME ["/data"] + +CMD ["url-shortener"] diff --git a/README.md b/README.md index d1fd14b..edc0582 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,143 @@ -# urlshortener +# Go URL Shortener mit PocketID Login +Ein Docker-fähiger URL-Shortener in Go mit SQLite, Web-UI, PocketID/OIDC-Login und Link-Verwaltung pro Nutzer. + +## Features + +- PocketID/OIDC Login über Authorization Code Flow +- Signiertes HttpOnly Session-Cookie +- Nutzergebundene Links über `owner_sub` +- Links erstellen, anzeigen, ändern und löschen +- Öffentliche Redirects über `/{code}` +- SQLite bleibt als Datenbank +- Dockerfile und docker-compose inklusive +- `AUTH_MODE=local` als lokaler Demo-Modus ohne PocketID + +## Start im lokalen Demo-Modus + +```bash +docker compose up --build +``` + +Dann öffnen: + +```text +http://localhost:8080 +``` + +Im Standard ist `AUTH_MODE=local` aktiv. Der Login-Button erzeugt dann eine lokale Demo-Session. + +## PocketID konfigurieren + +In PocketID einen OIDC Client anlegen: + +- Confidential Client / Public Client aus +- Client ID und Client Secret notieren +- Callback URL eintragen: + +```text +http://localhost:8080/auth/callback +``` + +Für produktive Deployments entsprechend deine echte HTTPS-URL verwenden, zum Beispiel: + +```text +https://links.example.com/auth/callback +``` + +PocketID wird als normaler OIDC Provider verwendet. `OIDC_ISSUER` ist die Basis-URL deiner PocketID-Instanz, ohne trailing slash und ohne `/.well-known/openid-configuration`. + +## docker-compose mit PocketID + +In `docker-compose.yml` die Environment-Variablen setzen: + +```yaml +environment: + ADDR: ":8080" + BASE_URL: "https://links.example.com" + DB_PATH: "/data/shortener.db" + + AUTH_MODE: "oidc" + OIDC_ISSUER: "https://pocketid.example.com" + OIDC_CLIENT_ID: "deine-client-id" + OIDC_CLIENT_SECRET: "dein-client-secret" + OIDC_REDIRECT_URL: "https://links.example.com/auth/callback" + OIDC_SCOPES: "openid profile email" + SESSION_SECRET: "bitte-mindestens-32-zeichen-langer-geheimer-string" +``` + +Dann: + +```bash +docker compose up --build +``` + +## API + +### Aktuellen Nutzer lesen + +```bash +curl http://localhost:8080/api/me +``` + +### URL kürzen + +Erfordert Login-Session. + +```bash +curl -X POST http://localhost:8080/api/shorten \ + -H "Content-Type: application/json" \ + -d '{"url":"https://example.com/some/very/long/path"}' +``` + +### Eigene Links anzeigen + +```bash +curl http://localhost:8080/api/links +``` + +### Link ändern + +```bash +curl -X PUT http://localhost:8080/api/links/abc123X \ + -H "Content-Type: application/json" \ + -d '{"url":"https://example.com/new-target"}' +``` + +### Link löschen + +```bash +curl -X DELETE http://localhost:8080/api/links/abc123X +``` + +## Konfiguration + +| Variable | Standard | Beschreibung | +|---|---:|---| +| `ADDR` | `:8080` | Listen-Adresse | +| `BASE_URL` | `http://localhost:8080` | Basis für erzeugte Kurz-URLs | +| `DB_PATH` | `/data/shortener.db` | SQLite-Datenbankpfad | +| `AUTH_MODE` | `local` | `local` oder `oidc` | +| `OIDC_ISSUER` | leer | Basis-URL der PocketID-Instanz | +| `OIDC_CLIENT_ID` | leer | OIDC Client ID | +| `OIDC_CLIENT_SECRET` | leer | OIDC Client Secret | +| `OIDC_REDIRECT_URL` | leer | Muss zur PocketID Callback URL passen | +| `OIDC_SCOPES` | `openid profile email` | OIDC Scopes | +| `SESSION_SECRET` | zufällig bei Start | Mindestens 32 Byte/Zeichen, in Produktion fest setzen | + +## Lokaler Start ohne Docker + +```bash +go mod tidy +AUTH_MODE=local DB_PATH=./shortener.db go run . +``` + +## Hinweise zur Migration + +Die App ergänzt alte SQLite-Datenbanken sanft um: + +- `owner_sub` +- `owner_name` +- `updated_at` + +Alte Links ohne Besitzer bleiben öffentlich weiterleitbar, erscheinen aber keinem Nutzer im Dashboard. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7b9a66e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + url-shortener: + build: . + container_name: go-url-shortener + ports: + - "8080:8080" + environment: + ADDR: ":8080" + BASE_URL: "http://localhost:8080" + DB_PATH: "/data/shortener.db" + + # local = Demo-Login ohne OIDC. Für PocketID auf oidc setzen. + AUTH_MODE: "local" + + # PocketID/OIDC Beispiel: + # AUTH_MODE: "oidc" + # OIDC_ISSUER: "https://pocketid.example.com" + # OIDC_CLIENT_ID: "deine-client-id" + # OIDC_CLIENT_SECRET: "dein-client-secret" + # OIDC_REDIRECT_URL: "http://localhost:8080/auth/callback" + # OIDC_SCOPES: "openid profile email" + # SESSION_SECRET: "bitte-mindestens-32-zeichen-langer-geheimer-string" + volumes: + - shortener-data:/data + restart: unless-stopped + +volumes: + shortener-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b69983a --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module url-shortener + +go 1.22 + +require modernc.org/sqlite v1.29.10 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.19.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.49.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d326e06 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= +modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= +modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= +modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..166a113 --- /dev/null +++ b/main.go @@ -0,0 +1,732 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "embed" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +//go:embed web/* +var webFiles embed.FS + +type AuthMode string + +const ( + AuthModeLocal AuthMode = "local" + AuthModeOIDC AuthMode = "oidc" +) + +type AuthConfig struct { + Mode AuthMode + Issuer string + ClientID string + ClientSecret string + RedirectURL string + Scopes []string + SessionSecret []byte +} + +type oidcDiscovery struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + Issuer string `json:"issuer"` +} + +type oauthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` +} + +type userClaims struct { + Subject string `json:"sub"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + Email string `json:"email"` +} + +type sessionData struct { + Username string `json:"username"` + Subject string `json:"sub,omitempty"` + Email string `json:"email,omitempty"` + Expires int64 `json:"exp"` +} + +type Server struct { + db *sql.DB + baseURL string + auth AuthConfig + oidc oidcDiscovery +} + +type Link struct { + Code string `json:"code"` + ShortURL string `json:"short_url"` + LongURL string `json:"long_url"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateRequest struct { + URL string `json:"url"` +} + +type UpdateRequest struct { + URL string `json:"url"` +} + +type CreateResponse struct { + Code string `json:"code"` + ShortURL string `json:"short_url"` + LongURL string `json:"long_url"` +} + +type UserResponse struct { + Authenticated bool `json:"authenticated"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + AuthMode string `json:"auth_mode"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +func main() { + addr := env("ADDR", ":8081") + dbPath := env("DB_PATH", "/data/shortener.db") + baseURL := strings.TrimRight(env("BASE_URL", "http://localhost:8081"), "/") + + auth, err := authConfigFromEnv() + if err != nil { + log.Fatal(err) + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := migrate(db); err != nil { + log.Fatal(err) + } + + s := &Server{db: db, baseURL: baseURL, auth: auth} + if err := s.discoverOIDC(); err != nil { + log.Fatal(err) + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.health) + mux.HandleFunc("GET /api/me", s.me) + mux.HandleFunc("GET /api/links", s.requireAuth(s.listLinks)) + mux.HandleFunc("POST /api/shorten", s.requireAuth(s.createShortURL)) + mux.HandleFunc("PUT /api/links/{code}", s.requireAuth(s.updateLink)) + mux.HandleFunc("DELETE /api/links/{code}", s.requireAuth(s.deleteLink)) + + mux.HandleFunc("GET /auth/login", s.handleLogin) + mux.HandleFunc("GET /auth/callback", s.handleOIDCCallback) + mux.HandleFunc("POST /auth/logout", s.handleLogout) + + staticFiles, err := fs.Sub(webFiles, "web") + if err != nil { + log.Fatal(err) + } + mux.Handle("GET /assets/", http.FileServerFS(staticFiles)) + mux.HandleFunc("GET /", s.homeOrRedirect(staticFiles)) + + log.Printf("URL shortener listening on %s", addr) + log.Printf("Base URL: %s", baseURL) + log.Printf("Auth mode: %s", auth.Mode) + + if err := http.ListenAndServe(addr, logging(mux)); err != nil { + log.Fatal(err) + } +} + +func migrate(db *sql.DB) error { + _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS links ( + code TEXT PRIMARY KEY, + long_url TEXT NOT NULL, + owner_sub TEXT NOT NULL DEFAULT '', + owner_name TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_links_owner_sub ON links(owner_sub); +CREATE INDEX IF NOT EXISTS idx_links_created_at ON links(created_at); +`) + if err != nil { + return err + } + + _ = addColumnIfMissing(db, "links", "owner_sub", "TEXT NOT NULL DEFAULT ''") + _ = addColumnIfMissing(db, "links", "owner_name", "TEXT NOT NULL DEFAULT ''") + _ = addColumnIfMissing(db, "links", "updated_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP") + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_links_owner_sub ON links(owner_sub)`) + return nil +} + +func addColumnIfMissing(db *sql.DB, table, column, definition string) error { + rows, err := db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var cid int + var name, typ string + var notNull int + var dflt any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + return err + } + if name == column { + return nil + } + } + _, err = db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + column + ` ` + definition) + return err +} + +func (s *Server) health(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + session, ok := s.readSession(r) + if !ok { + writeJSON(w, http.StatusOK, UserResponse{Authenticated: false, AuthMode: string(s.auth.Mode)}) + return + } + writeJSON(w, http.StatusOK, UserResponse{Authenticated: true, Username: session.Username, Email: session.Email, AuthMode: string(s.auth.Mode)}) +} + +func (s *Server) listLinks(w http.ResponseWriter, r *http.Request, session sessionData) { + rows, err := s.db.Query(` +SELECT code, long_url, created_at +FROM links +WHERE owner_sub = ? +ORDER BY created_at DESC +LIMIT 200`, session.Subject) + if err != nil { + log.Printf("list links failed: %v", err) + writeError(w, http.StatusInternalServerError, "could not load links") + return + } + defer rows.Close() + + links := make([]Link, 0) + for rows.Next() { + var l Link + if err := rows.Scan(&l.Code, &l.LongURL, &l.CreatedAt); err != nil { + writeError(w, http.StatusInternalServerError, "could not read links") + return + } + l.ShortURL = s.baseURL + "/" + l.Code + links = append(links, l) + } + writeJSON(w, http.StatusOK, links) +} + +func (s *Server) createShortURL(w http.ResponseWriter, r *http.Request, session sessionData) { + var req CreateRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + longURL := strings.TrimSpace(req.URL) + if !validHTTPURL(longURL) { + writeError(w, http.StatusBadRequest, "url must be a valid http or https URL") + return + } + + code, err := s.insertWithRandomCode(longURL, session) + if err != nil { + log.Printf("create short url failed: %v", err) + writeError(w, http.StatusInternalServerError, "could not create short URL") + return + } + + writeJSON(w, http.StatusCreated, CreateResponse{Code: code, ShortURL: s.baseURL + "/" + code, LongURL: longURL}) +} + +func (s *Server) updateLink(w http.ResponseWriter, r *http.Request, session sessionData) { + code := strings.TrimSpace(r.PathValue("code")) + var req UpdateRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + longURL := strings.TrimSpace(req.URL) + if !validHTTPURL(longURL) { + writeError(w, http.StatusBadRequest, "url must be a valid http or https URL") + return + } + + res, err := s.db.Exec(` +UPDATE links +SET long_url = ?, updated_at = ? +WHERE code = ? AND owner_sub = ?`, longURL, time.Now().UTC(), code, session.Subject) + if err != nil { + log.Printf("update link failed: %v", err) + writeError(w, http.StatusInternalServerError, "could not update link") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + writeError(w, http.StatusNotFound, "link not found") + return + } + + writeJSON(w, http.StatusOK, CreateResponse{Code: code, ShortURL: s.baseURL + "/" + code, LongURL: longURL}) +} + +func (s *Server) deleteLink(w http.ResponseWriter, r *http.Request, session sessionData) { + code := strings.TrimSpace(r.PathValue("code")) + res, err := s.db.Exec(`DELETE FROM links WHERE code = ? AND owner_sub = ?`, code, session.Subject) + if err != nil { + log.Printf("delete link failed: %v", err) + writeError(w, http.StatusInternalServerError, "could not delete link") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + writeError(w, http.StatusNotFound, "link not found") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) homeOrRedirect(staticFiles fs.FS) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + path := strings.Trim(r.URL.Path, "/") + if path == "" { + serveIndex(w, r, staticFiles) + return + } + if path == "favicon.ico" || strings.HasPrefix(path, "assets/") || strings.HasPrefix(path, "api/") || strings.HasPrefix(path, "auth/") { + http.NotFound(w, r) + return + } + s.redirect(w, r, path) + } +} + +func serveIndex(w http.ResponseWriter, r *http.Request, staticFiles fs.FS) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeFileFS(w, r, staticFiles, "index.html") +} + +func (s *Server) redirect(w http.ResponseWriter, r *http.Request, code string) { + var longURL string + err := s.db.QueryRow(`SELECT long_url FROM links WHERE code = ?`, code).Scan(&longURL) + if errors.Is(err, sql.ErrNoRows) { + http.NotFound(w, r) + return + } + if err != nil { + log.Printf("lookup failed: %v", err) + writeError(w, http.StatusInternalServerError, "lookup failed") + return + } + http.Redirect(w, r, longURL, http.StatusFound) +} + +func (s *Server) insertWithRandomCode(longURL string, session sessionData) (string, error) { + for range 8 { + code, err := randomCode(7) + if err != nil { + return "", err + } + now := time.Now().UTC() + _, err = s.db.Exec(`INSERT INTO links(code, long_url, owner_sub, owner_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, code, longURL, session.Subject, session.Username, now, now) + if err == nil { + return code, nil + } + if !strings.Contains(strings.ToLower(err.Error()), "constraint") { + return "", err + } + } + return "", errors.New("could not generate unique code") +} + +func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, sessionData)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session, ok := s.readSession(r) + if !ok { + writeError(w, http.StatusUnauthorized, "login required") + return + } + next(w, r, session) + } +} + +func authConfigFromEnv() (AuthConfig, error) { + mode := AuthMode(strings.ToLower(strings.TrimSpace(env("AUTH_MODE", "local")))) + if mode != AuthModeLocal && mode != AuthModeOIDC { + return AuthConfig{}, fmt.Errorf("unsupported AUTH_MODE %q", mode) + } + + secret := strings.TrimSpace(os.Getenv("SESSION_SECRET")) + var secretBytes []byte + if secret == "" { + secretBytes = make([]byte, 32) + if _, err := rand.Read(secretBytes); err != nil { + return AuthConfig{}, err + } + log.Printf("SESSION_SECRET is not set; generated a temporary secret. Existing sessions will be invalid after restart") + } else { + decoded, err := base64.StdEncoding.DecodeString(secret) + if err == nil && len(decoded) >= 32 { + secretBytes = decoded + } else { + secretBytes = []byte(secret) + } + if len(secretBytes) < 32 { + return AuthConfig{}, errors.New("SESSION_SECRET must be at least 32 bytes or a base64 encoded 32 byte value") + } + } + + cfg := AuthConfig{ + Mode: mode, + Issuer: strings.TrimRight(strings.TrimSpace(os.Getenv("OIDC_ISSUER")), "/"), + ClientID: strings.TrimSpace(os.Getenv("OIDC_CLIENT_ID")), + ClientSecret: strings.TrimSpace(os.Getenv("OIDC_CLIENT_SECRET")), + RedirectURL: strings.TrimSpace(os.Getenv("OIDC_REDIRECT_URL")), + Scopes: splitScopes(env("OIDC_SCOPES", "openid profile email")), + SessionSecret: secretBytes, + } + + if cfg.Mode == AuthModeOIDC { + if cfg.Issuer == "" || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" { + return AuthConfig{}, errors.New("AUTH_MODE=oidc requires OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_REDIRECT_URL") + } + } + return cfg, nil +} + +func (s *Server) discoverOIDC() error { + if s.auth.Mode != AuthModeOIDC { + return nil + } + discoveryURL := s.auth.Issuer + "/.well-known/openid-configuration" + req, err := http.NewRequest(http.MethodGet, discoveryURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("OIDC discovery failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("OIDC discovery returned %s", resp.Status) + } + var d oidcDiscovery + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&d); err != nil { + return err + } + if d.AuthorizationEndpoint == "" || d.TokenEndpoint == "" || d.UserInfoEndpoint == "" { + return errors.New("OIDC discovery response is missing required endpoints") + } + s.oidc = d + return nil +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if s.auth.Mode == AuthModeLocal { + _ = s.setSession(w, r, sessionData{Username: "local-user", Subject: "local-user", Expires: time.Now().Add(30 * 24 * time.Hour).Unix()}) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + s.handleOIDCLogin(w, r) +} + +func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) { + state, err := randomString(32) + if err != nil { + http.Error(w, "could not create login state", http.StatusInternalServerError) + return + } + setCookie(w, &http.Cookie{Name: "oauth_state", Value: state, MaxAge: 10 * 60, Path: "/auth", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure(r)}) + + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", s.auth.ClientID) + q.Set("redirect_uri", s.auth.RedirectURL) + q.Set("scope", strings.Join(s.auth.Scopes, " ")) + q.Set("state", state) + http.Redirect(w, r, s.oidc.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound) +} + +func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { + if s.auth.Mode != AuthModeOIDC { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if errText := r.URL.Query().Get("error"); errText != "" { + http.Error(w, "login cancelled: "+errText, http.StatusUnauthorized) + return + } + stateCookie, err := r.Cookie("oauth_state") + if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") { + http.Error(w, "invalid oauth state", http.StatusBadRequest) + return + } + clearCookie(w, "oauth_state", "/auth") + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + return + } + + tok, err := s.exchangeCode(r.Context(), code) + if err != nil { + log.Printf("oauth token exchange failed: %v", err) + http.Error(w, "token exchange failed", http.StatusBadGateway) + return + } + claims, err := s.fetchUserInfo(r.Context(), tok.AccessToken) + if err != nil { + log.Printf("oauth userinfo failed: %v", err) + http.Error(w, "could not fetch user info", http.StatusBadGateway) + return + } + + username := normalizeUsername(claims) + if username == "" || strings.TrimSpace(claims.Subject) == "" { + http.Error(w, "identity provider did not return usable identity data", http.StatusBadGateway) + return + } + if err := s.setSession(w, r, sessionData{Username: username, Subject: claims.Subject, Email: claims.Email, Expires: time.Now().Add(30 * 24 * time.Hour).Unix()}); err != nil { + http.Error(w, "could not create session", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) exchangeCode(ctx context.Context, code string) (oauthTokenResponse, error) { + values := url.Values{} + values.Set("grant_type", "authorization_code") + values.Set("code", code) + values.Set("redirect_uri", s.auth.RedirectURL) + values.Set("client_id", s.auth.ClientID) + values.Set("client_secret", s.auth.ClientSecret) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.oidc.TokenEndpoint, strings.NewReader(values.Encode())) + if err != nil { + return oauthTokenResponse{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return oauthTokenResponse{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return oauthTokenResponse{}, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return oauthTokenResponse{}, fmt.Errorf("token endpoint returned %s: %s", resp.Status, string(body)) + } + var tok oauthTokenResponse + if err := json.Unmarshal(body, &tok); err != nil { + return oauthTokenResponse{}, err + } + if tok.AccessToken == "" { + return oauthTokenResponse{}, errors.New("token endpoint did not return access_token") + } + return tok, nil +} + +func (s *Server) fetchUserInfo(ctx context.Context, accessToken string) (userClaims, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.oidc.UserInfoEndpoint, nil) + if err != nil { + return userClaims{}, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return userClaims{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return userClaims{}, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return userClaims{}, fmt.Errorf("userinfo endpoint returned %s: %s", resp.Status, string(body)) + } + var claims userClaims + if err := json.Unmarshal(body, &claims); err != nil { + return userClaims{}, err + } + return claims, nil +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + clearCookie(w, "shortener_session", "/") + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) setSession(w http.ResponseWriter, r *http.Request, data sessionData) error { + b, err := json.Marshal(data) + if err != nil { + return err + } + payload := base64.RawURLEncoding.EncodeToString(b) + sig := sign(payload, s.auth.SessionSecret) + setCookie(w, &http.Cookie{Name: "shortener_session", Value: payload + "." + sig, Path: "/", MaxAge: 60 * 60 * 24 * 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure(r)}) + return nil +} + +func (s *Server) readSession(r *http.Request) (sessionData, bool) { + c, err := r.Cookie("shortener_session") + if err != nil || c.Value == "" { + return sessionData{}, false + } + parts := strings.Split(c.Value, ".") + if len(parts) != 2 || !verify(parts[0], parts[1], s.auth.SessionSecret) { + return sessionData{}, false + } + b, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return sessionData{}, false + } + var data sessionData + if err := json.Unmarshal(b, &data); err != nil { + return sessionData{}, false + } + if data.Expires < time.Now().Unix() || strings.TrimSpace(data.Username) == "" || strings.TrimSpace(data.Subject) == "" { + return sessionData{}, false + } + return data, true +} + +func randomCode(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err + } + code := base64.RawURLEncoding.EncodeToString(b) + if len(code) > length { + code = code[:length] + } + return code, nil +} + +func validHTTPURL(raw string) bool { + u, err := url.ParseRequestURI(raw) + if err != nil { + return false + } + return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, ErrorResponse{Error: msg}) +} + +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func splitScopes(s string) []string { + fields := strings.Fields(s) + if len(fields) == 0 { + return []string{"openid", "profile", "email"} + } + return fields +} + +func sign(payload string, secret []byte) string { + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func verify(payload, got string, secret []byte) bool { + expected := sign(payload, secret) + return hmac.Equal([]byte(expected), []byte(got)) +} + +func randomString(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func normalizeUsername(c userClaims) string { + for _, v := range []string{c.PreferredUsername, c.Name, c.Email, c.Subject} { + v = strings.TrimSpace(v) + if v != "" { + if strings.Contains(v, "@") { + v = strings.Split(v, "@")[0] + } + if len(v) > 40 { + v = v[:40] + } + return v + } + } + return "" +} + +func setCookie(w http.ResponseWriter, c *http.Cookie) { http.SetCookie(w, c) } + +func clearCookie(w http.ResponseWriter, name, path string) { + http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: path, MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) +} + +func isSecure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} diff --git a/web/assets/app.js b/web/assets/app.js new file mode 100644 index 0000000..c1dcc5f --- /dev/null +++ b/web/assets/app.js @@ -0,0 +1,218 @@ +const loginLink = document.querySelector("#login-link"); +const logoutButton = document.querySelector("#logout-button"); +const userChip = document.querySelector("#user-chip"); +const loggedOutPanel = document.querySelector("#logged-out-panel"); +const appPanel = document.querySelector("#app-panel"); +const linksSection = document.querySelector("#links-section"); +const form = document.querySelector("#shorten-form"); +const input = document.querySelector("#url"); +const button = document.querySelector("#submit-button"); +const alertBox = document.querySelector("#alert"); +const result = document.querySelector("#result"); +const shortUrl = document.querySelector("#short-url"); +const longUrl = document.querySelector("#long-url"); +const copyButton = document.querySelector("#copy-button"); +const refreshButton = document.querySelector("#refresh-button"); +const linksList = document.querySelector("#links-list"); +const emptyState = document.querySelector("#empty-state"); +const template = document.querySelector("#link-template"); + +let currentUser = null; + +async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + + if (response.status === 204) { + return null; + } + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(payload.error || "Die Anfrage ist fehlgeschlagen."); + } + + return payload; +} + +function showError(message) { + alertBox.textContent = message; + alertBox.hidden = false; +} + +function clearError() { + alertBox.textContent = ""; + alertBox.hidden = true; +} + +function setLoading(isLoading) { + button.disabled = isLoading; + button.textContent = isLoading ? "Kürze..." : "Kürzen"; +} + +function setAuthenticated(user) { + currentUser = user.authenticated ? user : null; + + if (currentUser) { + userChip.textContent = currentUser.email + ? `${currentUser.username} · ${currentUser.email}` + : currentUser.username; + userChip.hidden = false; + logoutButton.hidden = false; + loginLink.hidden = true; + loggedOutPanel.hidden = true; + appPanel.hidden = false; + linksSection.hidden = false; + loadLinks(); + } else { + userChip.hidden = true; + logoutButton.hidden = true; + loginLink.hidden = false; + loggedOutPanel.hidden = false; + appPanel.hidden = true; + linksSection.hidden = true; + } +} + +async function loadMe() { + const user = await api("/api/me"); + setAuthenticated(user); +} + +async function loadLinks() { + if (!currentUser) return; + + linksList.innerHTML = ""; + emptyState.hidden = true; + + try { + const links = await api("/api/links"); + emptyState.hidden = links.length !== 0; + links.forEach(renderLink); + } catch (error) { + showError(error.message); + } +} + +function renderLink(link) { + const node = template.content.cloneNode(true); + const card = node.querySelector(".link-card"); + const short = node.querySelector(".link-short"); + const long = node.querySelector(".link-long"); + const form = node.querySelector(".edit-form"); + const editInput = node.querySelector(".edit-input"); + const saveButton = node.querySelector(".save-button"); + const copyButton = node.querySelector(".copy-link-button"); + const deleteButton = node.querySelector(".delete-button"); + + short.href = link.short_url; + short.textContent = link.short_url; + long.textContent = link.long_url; + editInput.value = link.long_url; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + clearError(); + saveButton.disabled = true; + saveButton.textContent = "Speichere..."; + + try { + const updated = await api(`/api/links/${encodeURIComponent(link.code)}`, { + method: "PUT", + body: JSON.stringify({ url: editInput.value.trim() }), + }); + + long.textContent = updated.long_url; + editInput.value = updated.long_url; + saveButton.textContent = "Gespeichert"; + setTimeout(() => { + saveButton.textContent = "Speichern"; + }, 1200); + } catch (error) { + showError(error.message); + saveButton.textContent = "Speichern"; + } finally { + saveButton.disabled = false; + } + }); + + copyButton.addEventListener("click", async () => { + await copyToClipboard(link.short_url, copyButton); + }); + + deleteButton.addEventListener("click", async () => { + const ok = confirm("Diesen Kurzlink wirklich löschen?"); + if (!ok) return; + + clearError(); + try { + await api(`/api/links/${encodeURIComponent(link.code)}`, { method: "DELETE" }); + card.remove(); + if (!linksList.children.length) emptyState.hidden = false; + } catch (error) { + showError(error.message); + } + }); + + linksList.appendChild(node); +} + +async function copyToClipboard(value, targetButton) { + try { + await navigator.clipboard.writeText(value); + const old = targetButton.textContent; + targetButton.textContent = "Kopiert!"; + setTimeout(() => { + targetButton.textContent = old; + }, 1500); + } catch { + showError("Kopieren ist fehlgeschlagen. Bitte kopiere den Link manuell."); + } +} + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + clearError(); + result.hidden = true; + setLoading(true); + + try { + const payload = await api("/api/shorten", { + method: "POST", + body: JSON.stringify({ url: input.value.trim() }), + }); + + shortUrl.textContent = payload.short_url; + shortUrl.href = payload.short_url; + longUrl.textContent = payload.long_url; + result.hidden = false; + copyButton.textContent = "Kopieren"; + input.value = ""; + await loadLinks(); + } catch (error) { + showError(error.message); + } finally { + setLoading(false); + } +}); + +copyButton.addEventListener("click", async () => { + await copyToClipboard(shortUrl.href, copyButton); +}); + +refreshButton.addEventListener("click", loadLinks); + +logoutButton.addEventListener("click", async () => { + await fetch("/auth/logout", { method: "POST" }); + window.location.href = "/"; +}); + +loadMe().catch((error) => { + console.error(error); +}); diff --git a/web/assets/styles.css b/web/assets/styles.css new file mode 100644 index 0000000..9a90727 --- /dev/null +++ b/web/assets/styles.css @@ -0,0 +1,245 @@ +:root { + color-scheme: dark; + --bg: #080b16; + --card: rgba(255, 255, 255, 0.08); + --card-strong: rgba(255, 255, 255, 0.13); + --text: #f8fafc; + --muted: #a6b0c3; + --line: rgba(255, 255, 255, 0.16); + --accent: #7c3aed; + --accent-2: #06b6d4; + --success: #34d399; + --danger: #fb7185; + --shadow: 0 28px 80px rgba(0, 0, 0, 0.45); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + radial-gradient(circle at 20% 10%, rgba(124, 58, 237, 0.35), transparent 32rem), + radial-gradient(circle at 82% 18%, rgba(6, 182, 212, 0.22), transparent 28rem), + linear-gradient(135deg, #080b16 0%, #101323 52%, #080b16 100%); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.045) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent 70%); +} + +a { color: inherit; } +button, input { font: inherit; } + +.page-shell { + position: relative; + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 24px 0 56px; +} + +.hero { min-height: 72vh; } + +.nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 72px; +} + +.brand, .nav-pill, .primary-link { text-decoration: none; } + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + font-weight: 800; + letter-spacing: -0.03em; + font-size: 1.1rem; +} + +.brand-mark { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 14px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + box-shadow: 0 16px 34px rgba(124, 58, 237, 0.36); +} + +.nav-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.nav-pill, .nav-button, .ghost-button { + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: rgba(255, 255, 255, 0.06); + transition: 0.2s ease; +} + +.nav-button, .ghost-button { cursor: pointer; } +.nav-pill:hover, .ghost-button:hover { color: var(--text); border-color: rgba(255, 255, 255, 0.32); transform: translateY(-1px); } + +.user-chip { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + padding: 10px 14px; + border: 1px solid rgba(52, 211, 153, 0.28); + border-radius: 999px; + color: #bbf7d0; + background: rgba(52, 211, 153, 0.1); + white-space: nowrap; +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 460px; + gap: 56px; + align-items: center; +} + +.eyebrow, .card-kicker, .result-label { + margin: 0 0 10px; + color: #93c5fd; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +h1, h2, h3, p { margin-top: 0; } + +h1 { + max-width: 760px; + margin-bottom: 22px; + font-size: clamp(3rem, 7vw, 5.8rem); + line-height: 0.9; + letter-spacing: -0.08em; +} + +.subline { + max-width: 650px; + color: var(--muted); + font-size: clamp(1.05rem, 2vw, 1.25rem); + line-height: 1.7; +} + +.stats { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 34px; } +.stats div { min-width: 116px; padding: 16px; border: 1px solid var(--line); border-radius: 22px; background: rgba(255, 255, 255, 0.055); backdrop-filter: blur(16px); } +.stats strong { display: block; margin-bottom: 4px; font-size: 1.45rem; letter-spacing: -0.06em; } +.stats span { color: var(--muted); font-size: 0.9rem; } + +.card, .links-section { + position: relative; + overflow: hidden; + padding: 26px; + border: 1px solid var(--line); + border-radius: 34px; + background: linear-gradient(180deg, var(--card-strong), var(--card)); + box-shadow: var(--shadow); + backdrop-filter: blur(24px); +} + +.card::before, .links-section::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), transparent 36%); + opacity: 0.35; +} + +.card > *, .links-section > * { position: relative; } +.card-header, .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; } +.card h2, .section-heading h2 { margin-bottom: 0; font-size: 2rem; letter-spacing: -0.05em; } + +.sparkle { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 18px; background: rgba(255, 255, 255, 0.1); color: #fde68a; } +.login-panel .primary-link { display: inline-flex; margin-top: 18px; } + +.primary-link, #submit-button { + padding: 15px 20px; + border-radius: 18px; + color: white; + font-weight: 800; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + box-shadow: 0 16px 30px rgba(6, 182, 212, 0.18); + transition: 0.2s ease; +} +.primary-link:hover, #submit-button:hover { transform: translateY(-1px); box-shadow: 0 20px 36px rgba(6, 182, 212, 0.26); } + +.form label { display: block; margin-bottom: 10px; color: var(--muted); font-weight: 700; } +.input-row { display: flex; gap: 10px; } + +input { + min-width: 0; + width: 100%; + padding: 16px 17px; + border: 1px solid var(--line); + border-radius: 18px; + outline: none; + color: var(--text); + background: rgba(8, 11, 22, 0.68); + transition: 0.2s ease; +} +input:focus { border-color: rgba(6, 182, 212, 0.65); box-shadow: 0 0 0 4px rgba(6, 182, 212, 0.14); } +button { border: 0; cursor: pointer; white-space: nowrap; font-weight: 800; } +#submit-button { border: 0; } +#submit-button:disabled { cursor: progress; opacity: 0.7; transform: none; } + +.hint, .long-url, .link-long { margin: 10px 0 0; color: var(--muted); font-size: 0.92rem; } +.bigger { line-height: 1.6; font-size: 1rem; } +.alert { margin-top: 18px; padding: 14px 16px; border: 1px solid rgba(251, 113, 133, 0.35); border-radius: 18px; color: #fecdd3; background: rgba(251, 113, 133, 0.12); } + +.result { margin-top: 22px; padding-top: 22px; border-top: 1px solid var(--line); } +.result-box { display: flex; gap: 10px; align-items: center; padding: 10px; border: 1px solid rgba(52, 211, 153, 0.32); border-radius: 22px; background: rgba(52, 211, 153, 0.09); } +#short-url, .link-short { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; color: #bbf7d0; font-weight: 800; text-decoration: none; white-space: nowrap; } +#copy-button, .small-button { padding: 12px 14px; border-radius: 15px; color: var(--text); background: rgba(255, 255, 255, 0.12); transition: 0.2s ease; } +#copy-button:hover, .small-button:hover { background: rgba(255, 255, 255, 0.2); } + +.links-section { margin-top: 40px; } +.empty-state { padding: 22px; border: 1px dashed var(--line); border-radius: 22px; color: var(--muted); text-align: center; } +.links-list { display: grid; gap: 14px; } +.link-card { display: grid; gap: 14px; padding: 18px; border: 1px solid var(--line); border-radius: 24px; background: rgba(255, 255, 255, 0.055); } +.link-main { min-width: 0; } +.edit-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; } +.edit-input { padding: 13px 14px; border-radius: 15px; } +.link-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.danger-button { color: #fecdd3; background: rgba(251, 113, 133, 0.13); } +.danger-button:hover { background: rgba(251, 113, 133, 0.22); } + +@media (max-width: 900px) { + .nav { margin-bottom: 42px; } + .hero-grid { grid-template-columns: 1fr; gap: 34px; } + .card { max-width: 680px; } + .edit-form { grid-template-columns: 1fr; } +} + +@media (max-width: 560px) { + .page-shell { width: min(100% - 22px, 1120px); padding-top: 16px; } + .nav { align-items: flex-start; margin-bottom: 34px; } + h1 { font-size: clamp(2.7rem, 17vw, 4.2rem); } + .input-row, .result-box, .section-heading { flex-direction: column; align-items: stretch; } + #submit-button, #copy-button, .small-button { min-height: 52px; } + .stats { display: grid; grid-template-columns: 1fr; } + .link-actions { display: grid; grid-template-columns: 1fr; } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c32b76e --- /dev/null +++ b/web/index.html @@ -0,0 +1,117 @@ + + + + + + LinkForge · URL Shortener + + + + +
+
+ + +
+
+

Veröffentlichte URLs flexibel steuern: Zieladressen ändern, ohne den eingebundenen Link anzupassen.

+

Flexible Veröffentlichung. Veränderbare Endpunkte.

+

+ Anmelden, Kurzlinks erstellen und verwalten. Direkt im Browser. +

+ +
+
SchutzSichere Anmeldung
+
KontrolleLinks ändern
+
SicherheitMaximale Kontrolle
+
+
+ +
+ + + +
+
+
+ + +
+ + + + + +