init
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
shortener.db
|
||||
data
|
||||
*.zip
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -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"]
|
||||
143
README.md
143
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.
|
||||
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -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:
|
||||
21
go.mod
Normal file
21
go.mod
Normal file
@@ -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
|
||||
)
|
||||
49
go.sum
Normal file
49
go.sum
Normal file
@@ -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=
|
||||
732
main.go
Normal file
732
main.go
Normal file
@@ -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))
|
||||
})
|
||||
}
|
||||
218
web/assets/app.js
Normal file
218
web/assets/app.js
Normal file
@@ -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);
|
||||
});
|
||||
245
web/assets/styles.css
Normal file
245
web/assets/styles.css
Normal file
@@ -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; }
|
||||
}
|
||||
117
web/index.html
Normal file
117
web/index.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LinkForge · URL Shortener</title>
|
||||
<meta name="description" content="Ein schneller, schöner URL-Shortener in Go mit PocketID Login.">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page-shell">
|
||||
<section class="hero">
|
||||
<nav class="nav">
|
||||
<a class="brand" href="/" aria-label="LinkForge Startseite">
|
||||
<span class="brand-mark">↗</span>
|
||||
<span>LinkForge</span>
|
||||
</a>
|
||||
<div class="nav-actions">
|
||||
<span id="user-chip" class="user-chip" hidden></span>
|
||||
<button id="logout-button" class="nav-pill nav-button" type="button" hidden>Abmelden</button>
|
||||
<a id="login-link" class="nav-pill" href="/auth/login">Sicher anmelden</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="hero-grid">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Veröffentlichte URLs flexibel steuern: Zieladressen ändern, ohne den eingebundenen Link anzupassen.</p>
|
||||
<h1>Flexible Veröffentlichung. Veränderbare Endpunkte.</h1>
|
||||
<p class="subline">
|
||||
Anmelden, Kurzlinks erstellen und verwalten. Direkt im Browser.
|
||||
</p>
|
||||
|
||||
<div class="stats" aria-label="Projekt Features">
|
||||
<div><strong>Schutz</strong><span>Sichere Anmeldung</span></div>
|
||||
<div><strong>Kontrolle</strong><span>Links ändern</span></div>
|
||||
<div><strong>Sicherheit</strong><span>Maximale Kontrolle</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card" aria-labelledby="form-title">
|
||||
<div id="logged-out-panel" class="login-panel">
|
||||
<p class="card-kicker">Geschützter Bereich</p>
|
||||
<h2>Anmelden erforderlich</h2>
|
||||
<p class="hint bigger">Bitte anmelden, um Kurzlinks zu erstellen und vorhandenen Links zu bearbeiten.</p>
|
||||
<a class="primary-link" href="/auth/login">Sicher anmelden</a>
|
||||
</div>
|
||||
|
||||
<div id="app-panel" hidden>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<p class="card-kicker">Neuer Kurzlink</p>
|
||||
<h2 id="form-title">URL kürzen</h2>
|
||||
</div>
|
||||
<div class="sparkle" aria-hidden="true">✦</div>
|
||||
</div>
|
||||
|
||||
<form id="shorten-form" class="form">
|
||||
<label for="url">Lange URL</label>
|
||||
<div class="input-row">
|
||||
<input id="url" name="url" type="url" placeholder="https://example.com/sehr/langer/link" required>
|
||||
<button id="submit-button" type="submit">Kürzen</button>
|
||||
</div>
|
||||
<p class="hint">Erlaubt sind HTTP- und HTTPS-URLs.</p>
|
||||
</form>
|
||||
|
||||
<div id="alert" class="alert" role="alert" hidden></div>
|
||||
|
||||
<section id="result" class="result" hidden>
|
||||
<p class="result-label">Kurzlinks</p>
|
||||
<div class="result-box">
|
||||
<a id="short-url" href="#" target="_blank" rel="noopener noreferrer"></a>
|
||||
<button id="copy-button" type="button">Kopieren</button>
|
||||
</div>
|
||||
<p id="long-url" class="long-url"></p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="links-section" class="links-section" hidden>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Dashboard</p>
|
||||
<h2>Eigene Links</h2>
|
||||
</div>
|
||||
<button id="refresh-button" class="ghost-button" type="button">Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty-state" hidden>
|
||||
Noch keine Links vorhanden. Erstelle oben deinen ersten Kurzlink.
|
||||
</div>
|
||||
|
||||
<div id="links-list" class="links-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<template id="link-template">
|
||||
<article class="link-card">
|
||||
<div class="link-main">
|
||||
<a class="link-short" target="_blank" rel="noopener noreferrer"></a>
|
||||
<p class="link-long"></p>
|
||||
</div>
|
||||
<form class="edit-form">
|
||||
<input class="edit-input" type="url" required>
|
||||
<div class="link-actions">
|
||||
<button class="small-button save-button" type="submit">Speichern</button>
|
||||
<button class="small-button copy-link-button" type="button">Kopieren</button>
|
||||
<button class="small-button danger-button delete-button" type="button">Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="/assets/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user