initial commit from original sev version

This commit is contained in:
Bartal Læarsson
2026-06-25 12:03:26 +01:00
commit 9c157cc5a0
31 changed files with 468875 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
# Abuse Registration API POC
A stripped-down, in-memory API proof-of-concept. It keeps the important authentication shape from the original API while removing backend/database business logic.
The demo data is synthetic, stored in memory, and reset to the base dataset every 10 minutes. The base dataset contains 546 rows with `registered_at` dates from 1988 through 1991.
The HTML template, CSS, JavaScript, and favicon are embedded with Go `embed`. A built binary can be copied and run by itself; it does not need a neighboring `static/`, `templates/`, or `favicon.ico` file.
The JSON model is intentionally small:
```json
{
"id": "bb8d39a6-8fef-48af-9f9c-27a41c8f8baf",
"registered_at": "1989-04-13T15:22:00Z",
"gender": "female",
"location": "Tórshavn",
"abuse_type": "psychological",
"status": "new"
}
```
`id` is generated by the server as a UUID. `registered_at` defaults to `time.Now().UTC()` when omitted on create or update.
## Run locally
```bash
go run ./cmd/api
```
Or with Task:
```bash
task run
```
Then open:
```text
http://localhost:8080/
```
Optional environment variables:
```bash
PORT=:9999 JWT_SECRET=change-me RESET_INTERVAL=10m go run ./cmd/api
```
## Build
The Taskfile follows the FLÓ-style build version format:
```text
version [<git-sha>-<branch>/<random-word>-<random-word>]. git is [clean|dirty].
```
`build/words.txt` is copied from the FLÓ website project.
```bash
task build
./bin/abuse-registration-poc
```
The resulting `./bin/abuse-registration-poc` is self-contained. You can copy just that file to another Linux host and run it.
## Project layout
The POC is intentionally small, but it is no longer a single-file prototype. The code is split in the same broad shape as the original API:
```text
cmd/api/main.go binary entry point and flags
internal/config environment/default configuration
internal/models user and registration models plus allowed values
internal/auth static POC users replacing the auth database
internal/utils JWT and UUID helpers
internal/store in-memory registration store and reset logic
internal/handlers HTTP handlers for login, health, page, and data
internal/middlewares authentication, authorization, and CORS
internal/routes route registration and protected route wiring
internal/web/templates/index.html single HTML page template, embedded into the binary
internal/web/static embedded CSS and browser JavaScript
internal/web/favicon.ico embedded favicon
```
The real database/repository layer is deliberately replaced by `internal/store`, but the login response shape, raw `Authorization` JWT usage, role split, and route-permission middleware are kept close to the original API. Web assets are separate source files, but are compiled into the binary at build time.
## Demo users
| User | Password | Role | Access |
|---|---|---|---|
| `reader` | `reader-password` | `Reader` | Read protected endpoints |
| `admin` | `admin-password` | `Admin` | Full create/read/update/delete and manual reset |
## Auth workflow
The site does not contain a login form and does not acquire a token in the browser. Use curl or PowerShell only.
Reader with curl:
```bash
READER_TOKEN=$(curl -s -X POST http://localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"user_name":"reader","password":"reader-password"}' \
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
curl -s 'http://localhost:8080/api/v1/registrations?location=Tórshavn&limit=5' \
-H "Authorization: $READER_TOKEN"
curl -s 'http://localhost:8080/api/v1/registrations?gender=female&abuse_type=psychological&status=open&limit=10' \
-H "Authorization: $READER_TOKEN"
curl -s 'http://localhost:8080/api/v1/registrations?from=1989-01-01&to=1990-01-01&offset=20&limit=10' \
-H "Authorization: $READER_TOKEN"
```
Reader with PowerShell:
```powershell
$reader = Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:8080/login" `
-ContentType "application/json" `
-Body '{"user_name":"reader","password":"reader-password"}'
$READER_TOKEN = $reader.token
Invoke-RestMethod `
-Uri "http://localhost:8080/api/v1/registrations?location=Tórshavn&limit=5" `
-Headers @{Authorization=$READER_TOKEN}
```
Admin workflow:
```bash
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"user_name":"admin","password":"admin-password"}' \
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
CREATED_ID=$(curl -s -X POST http://localhost:8080/api/v1/registrations \
-H "Authorization: $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"gender":"unknown","location":"Tórshavn","abuse_type":"psychological","status":"new"}' \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
curl -s -X PUT "http://localhost:8080/api/v1/registrations/$CREATED_ID" \
-H "Authorization: $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"gender":"female","location":"Skopun","abuse_type":"digital","status":"referred"}'
curl -s -X DELETE "http://localhost:8080/api/v1/registrations/$CREATED_ID" \
-H "Authorization: $ADMIN_TOKEN"
```
## Endpoint behavior
Public:
- `GET /` returns the single HTML page only. It does not embed all 546 registrations into the HTML.
- `GET /health`
- `POST /login`
- `GET /demo/registrations` is the public demo snapshot used by the page JSON viewer.
Protected read:
- `GET /api/v1/categories`
- `GET /api/v1/locations`
- `GET /api/v1/registrations`
- `GET /api/v1/registrations/{uuid}`
Admin create/read/update/delete:
- `POST /api/v1/registrations`
- `PUT /api/v1/registrations/{uuid}`
- `DELETE /api/v1/registrations/{uuid}`
- `POST /api/v1/reset`
Unknown normal pages return a plain text message. Unknown `/api/...` routes return JSON.
## Allowed values
Allowed `gender` values:
- `female`
- `male`
- `non_binary`
- `unknown`
Allowed `abuse_type` values:
- `physical`
- `psychological`
- `sexual`
- `economic`
- `material`
- `digital`
- `stalking`
- `threats`
- `honor_related`
Allowed `location` values are fixed to the included Faroese town/village list in `internal/models/registration.go`. The browser filter dropdown uses this same list, and create/update rejects anything else.
Filters for `GET /api/v1/registrations` and `/demo/registrations`:
- `abuse_type`
- `gender`
- `location` — exact match against the fixed list
- `status`
- `from` and `to`, matched against `registered_at`, accepting `YYYY-MM-DD` or RFC3339 values
- `search`
- `limit`
- `offset`
+100
View File
@@ -0,0 +1,100 @@
version: '3'
vars:
SERVER: bl@flo-homeserver
REMOTE_MACHINE: bl@flo-homeserver
PKG: main
BINARY_DIR: ./bin
DEV_BINARY_DIR: ./local
SHORT_SHA:
sh: git rev-parse --short HEAD 2>/dev/null || echo nogit
BRANCH:
sh: git rev-parse --abbrev-ref HEAD 2>/dev/null || echo local
DIRTY:
sh: if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then if git diff --quiet && git diff --cached --quiet; then echo clean; else echo dirty; fi; else echo clean; fi
RANDOM_WORDS:
sh: if [ -f build/words.txt ]; then awk 'BEGIN{srand()} /^[[:lower:]]+$/ && length($0) >= 4 && length($0) <= 10 { words[++n]=$0 } END { if (n < 1) { print "word-list"; exit } print words[int(rand()*n)+1] "-" words[int(rand()*n)+1] }' build/words.txt; else echo word-list; fi
VERSION_STR: "version [{{.SHORT_SHA}}-{{.BRANCH}}/{{.RANDOM_WORDS}}]. git is [{{.DIRTY}}]."
LD_FLAGS: "-X '{{.PKG}}.Version={{.VERSION_STR}}'"
BINARY_API: "{{.BINARY_DIR}}/abuse-registration-poc"
DEV_BINARY_API: "{{.DEV_BINARY_DIR}}/abuse-registration-poc/"
tasks:
# ── Build ──────────────────────────────────────────────────────────────────
build:
desc: runs vet, fmt, and build api
deps: [vet, fmt, build-api]
build-api:
desc: clean, then build api bin
cmds:
- task clean-api
- mkdir -p {{.BINARY_DIR}}
- echo "building {{.BINARY_API}} with version {{.VERSION_STR}}"
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "{{.LD_FLAGS}}" -o {{.BINARY_API}} ./cmd/api
# ── DEV Build ──────────────────────────────────────────────────────────────
dev-build:
desc: runs DEV for vet, fmt, and build DEV api
deps: [vet, fmt, dev-build-api]
dev-build-api:
desc: clean, then build DEV api bin
cmds:
- task dev-clean-api
- mkdir -p {{.DEV_BINARY_API}}
- echo "building DEV {{.DEV_BINARY_API}} with version {{.VERSION_STR}}"
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "{{.LD_FLAGS}}" -o {{.DEV_BINARY_API}}/abuse-registration-poc ./cmd/api
# ── Deploy individual ──────────────────────────────────────────────────────
deploy-api:
desc: build api, create remote dirs, and deploy api to remote vps
deps: [build-api]
cmds:
- ssh {{.REMOTE_MACHINE}} "mkdir -p /srv/abuse-registration-poc && rm -f /srv/abuse-registration-poc/abuse-registration-poc"
- rsync -avz --delete {{.BINARY_API}} {{.REMOTE_MACHINE}}:/srv/abuse-registration-poc/
- ssh -t {{.REMOTE_MACHINE}} "sudo systemctl restart abuse-registration-poc"
# ── DEV START individual ──────────────────────────────────────────────────
dev-start-api:
desc: build api, create dirs, and start DEV api binary
dir: "{{ .DEV_BINARY_API }}"
cmds:
- "rm -rf *"
- task dev-build-api
- "./abuse-registration-poc"
run:
desc: run the POC API locally on :8080 by default
cmds:
- go run ./cmd/api
# ── Helpers ────────────────────────────────────────────────────────────────
vet:
cmds:
- go vet ./...
fmt:
cmds:
- go fmt ./...
test:
cmds:
- go test ./...
clean:
deps: [clean-api]
clean-api:
cmds:
- rm -f {{.BINARY_API}}
dev-clean-api:
cmds:
- rm -f {{.DEV_BINARY_API}}/abuse-registration-poc
+466550
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
"abuse_registration_poc/internal/auth"
"abuse_registration_poc/internal/config"
"abuse_registration_poc/internal/handlers"
"abuse_registration_poc/internal/routes"
"abuse_registration_poc/internal/store"
)
// Version is injected by Taskfile through -ldflags.
var Version = "dev"
func main() {
releaseVersion := flag.Bool("version", false, "show binary version and shut down programme")
portOverride := flag.String("port", "", "override api.port from config (e.g. ':8080')")
envOverride := flag.String("env", "", "override environment from config (e.g. 'prod', 'dev')")
flag.Parse()
if *releaseVersion {
fmt.Printf("version matching git tag and branch: %s", Version)
os.Exit(0)
}
cfg := config.LoadConfig()
if *portOverride != "" {
log.Printf("Overriding api.port from config (%s) with flag value (%s)", cfg.API.Port, *portOverride)
cfg.API.Port = *portOverride
}
if *envOverride != "" {
log.Printf("Overriding environment from config (%s) with flag value (%s)", cfg.Environment, *envOverride)
cfg.Environment = *envOverride
}
config.SetAPIKey(cfg.Auth.JWTSecret)
registrationStore := store.NewMemoryStore(cfg.Dataset.Size, cfg.Dataset.ResetInterval)
userStore := auth.NewStaticUserStore()
h, err := handlers.New(handlers.Dependencies{
Store: registrationStore,
Users: userStore,
Version: Version,
AppName: cfg.API.Name,
BaseURL: cfg.API.BaseURL,
})
if err != nil {
log.Fatalf("Failed to initialize handlers: %v", err)
}
server := &http.Server{
Addr: cfg.API.Port,
Handler: routes.RegisterRoutes(h),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("starting Abuse Registration POC on port %s", cfg.API.Port)
time.Sleep(100 * time.Millisecond)
if err := server.ListenAndServe(); err != nil {
log.Fatalf("ERROR: Failed to start server: %v", err)
}
}
+24
View File
@@ -0,0 +1,24 @@
# Data model
This POC intentionally uses a very small anonymous registration object.
```json
{
"id": "bb8d39a6-8fef-48af-9f9c-27a41c8f8baf",
"registered_at": "1989-04-13T15:22:00Z",
"gender": "female",
"location": "Tórshavn",
"abuse_type": "psychological",
"status": "new"
}
```
Notes:
- `id` is a UUID string generated by the server.
- There is no name field.
- `registered_at` defaults to `time.Now().UTC()` when omitted on create or update.
- Base synthetic data uses dates from 1988 through 1991.
- Data resets to 546 base rows every 10 minutes.
- `location` must be one of the fixed Faroese towns/villages in `internal/models/registration.go`.
- `PUT /api/v1/registrations/{uuid}` is used for update. There is no `PATCH` endpoint.
+14
View File
@@ -0,0 +1,14 @@
module abuse_registration_poc
go 1.24.0
tool github.com/cespare/reflex
require (
github.com/cespare/reflex v0.3.2 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/ogier/pflag v0.0.1 // indirect
golang.org/x/sys v0.41.0 // indirect
)
+12
View File
@@ -0,0 +1,12 @@
github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ=
github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+45
View File
@@ -0,0 +1,45 @@
package auth
import (
"errors"
"sync"
"abuse_registration_poc/internal/models"
)
type UserStore struct {
mu sync.RWMutex
users map[string]models.User
}
func NewStaticUserStore() *UserStore {
return &UserStore{users: map[string]models.User{
"reader": {ID: 1, UserName: "reader", Password: "reader-password", Role: models.RoleReader},
"admin": {ID: 2, UserName: "admin", Password: "admin-password", Role: models.RoleAdmin},
}}
}
func (s *UserStore) ValidateCredentials(user *models.ValidateUser) error {
s.mu.RLock()
defer s.mu.RUnlock()
stored, ok := s.users[user.UserName]
if !ok || stored.Password != user.Password {
return errors.New("credentials invalid")
}
user.ID = stored.ID
user.Role = stored.Role
return nil
}
func (s *UserStore) FindByID(id int64) (models.User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, user := range s.users {
if user.ID == id {
return user, true
}
}
return models.User{}, false
}
+99
View File
@@ -0,0 +1,99 @@
package config
import (
"os"
"strconv"
"strings"
"time"
)
const (
DefaultPort = ":8080"
DefaultJWTSecret = "local-poc-secret-change-me"
DefaultResetInterval = 10 * time.Minute
DefaultDatasetSize = 546
)
// ApiKey mirrors the original API's JWT utility style: utils.GenerateToken and
// utils.VerifyToken read the signing secret from config.
var ApiKey = DefaultJWTSecret
type Config struct {
Environment string
API APIConfig
Auth AuthConfig
Dataset DatasetConfig
}
type APIConfig struct {
Name string
Port string
BaseURL string
}
type AuthConfig struct {
JWTSecret string
}
type DatasetConfig struct {
Size int
ResetInterval time.Duration
}
func LoadConfig() Config {
return Config{
Environment: envString("APP_ENV", "dev"),
API: APIConfig{
Name: envString("APP_NAME", "abuse-registration-poc"),
Port: envString("PORT", DefaultPort),
BaseURL: envString("BASE_URL", "http://localhost:8080"),
},
Auth: AuthConfig{
JWTSecret: envString("JWT_SECRET", DefaultJWTSecret),
},
Dataset: DatasetConfig{
Size: envInt("DATASET_SIZE", DefaultDatasetSize),
ResetInterval: envDuration("RESET_INTERVAL", DefaultResetInterval),
},
}
}
func SetAPIKey(value string) {
if strings.TrimSpace(value) == "" {
ApiKey = DefaultJWTSecret
return
}
ApiKey = value
}
func envString(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func envInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 {
return fallback
}
return parsed
}
func envDuration(key string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}
+41
View File
@@ -0,0 +1,41 @@
package handlers
import (
"html/template"
"abuse_registration_poc/internal/auth"
"abuse_registration_poc/internal/store"
"abuse_registration_poc/internal/web"
)
type Dependencies struct {
Store *store.MemoryStore
Users *auth.UserStore
Version string
AppName string
BaseURL string
}
type Handler struct {
store *store.MemoryStore
users *auth.UserStore
index *template.Template
version string
appName string
baseURL string
}
func New(deps Dependencies) (*Handler, error) {
index, err := template.ParseFS(web.Templates, "templates/index.html")
if err != nil {
return nil, err
}
return &Handler{
store: deps.Store,
users: deps.Users,
index: index,
version: deps.Version,
appName: deps.AppName,
baseURL: deps.BaseURL,
}, nil
}
+19
View File
@@ -0,0 +1,19 @@
package handlers
import (
"net/http"
"time"
)
func (h *Handler) Health(writer http.ResponseWriter, request *http.Request) {
rows, lastReset, nextReset := h.store.Snapshot()
WriteJSON(writer, http.StatusOK, map[string]any{
"status": "ok",
"service": h.appName,
"version": h.version,
"dataset_count": len(rows),
"reset_interval": h.store.ResetInterval().String(),
"last_reset": lastReset.Format(time.RFC3339),
"next_reset": nextReset.Format(time.RFC3339),
})
}
+32
View File
@@ -0,0 +1,32 @@
package handlers
import (
"net/http"
"time"
"abuse_registration_poc/internal/models"
)
func (h *Handler) Index(writer http.ResponseWriter, request *http.Request) {
_, _, nextReset := h.store.Snapshot()
data := struct {
Version string
NextReset string
Locations []string
Categories []string
Genders []string
Statuses []string
}{
Version: h.version,
NextReset: nextReset.Format(time.RFC3339),
Locations: models.FaroeLocations,
Categories: models.AbuseCategories,
Genders: models.Genders,
Statuses: models.Statuses,
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.index.ExecuteTemplate(writer, "index.html", data); err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
}
+128
View File
@@ -0,0 +1,128 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"time"
"abuse_registration_poc/internal/models"
)
func (h *Handler) DemoRegistrations(writer http.ResponseWriter, request *http.Request) {
WriteJSON(writer, http.StatusOK, h.store.List(filterFromQuery(request)))
}
func (h *Handler) ListRegistrations(writer http.ResponseWriter, request *http.Request) {
WriteJSON(writer, http.StatusOK, h.store.List(filterFromQuery(request)))
}
func (h *Handler) GetRegistration(writer http.ResponseWriter, request *http.Request, id string) {
registration, ok := h.store.Get(id)
if !ok {
WriteJSON(writer, http.StatusNotFound, map[string]string{"message": "Registration not found."})
return
}
WriteJSON(writer, http.StatusOK, registration)
}
func (h *Handler) CreateRegistration(writer http.ResponseWriter, request *http.Request) {
var input models.Registration
if err := DecodeJSON(request, &input); err != nil {
WriteJSON(writer, http.StatusBadRequest, map[string]string{"message": "Could not parse request data."})
return
}
created, err := h.store.Create(input)
if err != nil {
WriteJSON(writer, http.StatusBadRequest, map[string]string{"message": err.Error()})
return
}
WriteJSON(writer, http.StatusCreated, created)
}
func (h *Handler) UpdateRegistration(writer http.ResponseWriter, request *http.Request, id string) {
var input models.Registration
if err := DecodeJSON(request, &input); err != nil {
WriteJSON(writer, http.StatusBadRequest, map[string]string{"message": "Could not parse request data."})
return
}
updated, ok, err := h.store.Replace(id, input)
if err != nil {
WriteJSON(writer, http.StatusBadRequest, map[string]string{"message": err.Error()})
return
}
if !ok {
WriteJSON(writer, http.StatusNotFound, map[string]string{"message": "Registration not found."})
return
}
WriteJSON(writer, http.StatusOK, updated)
}
func (h *Handler) DeleteRegistration(writer http.ResponseWriter, request *http.Request, id string) {
if !h.store.Delete(id) {
WriteJSON(writer, http.StatusNotFound, map[string]string{"message": "Registration not found."})
return
}
WriteJSON(writer, http.StatusOK, map[string]any{"message": "Registration deleted successfully.", "id": id})
}
func (h *Handler) ResetRegistrations(writer http.ResponseWriter, request *http.Request) {
h.store.Reset()
rows, _, nextReset := h.store.Snapshot()
WriteJSON(writer, http.StatusOK, map[string]any{
"message": "Dataset reset to base sample data.",
"dataset_count": len(rows),
"next_reset": nextReset.Format(time.RFC3339),
})
}
func (h *Handler) Categories(writer http.ResponseWriter, request *http.Request) {
WriteJSON(writer, http.StatusOK, models.AbuseCategories)
}
func (h *Handler) Locations(writer http.ResponseWriter, request *http.Request) {
WriteJSON(writer, http.StatusOK, models.FaroeLocations)
}
func filterFromQuery(request *http.Request) models.RegistrationFilter {
query := request.URL.Query()
filter := models.RegistrationFilter{
Location: query.Get("location"),
AbuseType: query.Get("abuse_type"),
Gender: query.Get("gender"),
Status: query.Get("status"),
Search: query.Get("search"),
Limit: queryInt(query.Get("limit"), 0),
Offset: queryInt(query.Get("offset"), 0),
}
if from, ok := queryTime(query.Get("from")); ok {
filter.From = from
filter.HasFrom = true
}
if to, ok := queryTime(query.Get("to")); ok {
filter.To = to
filter.HasTo = true
}
return filter
}
func queryInt(value string, fallback int) int {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
return fallback
}
return parsed
}
func queryTime(value string) (time.Time, bool) {
parsed, err := models.ParseInputTime(value)
if err != nil {
return time.Time{}, false
}
return parsed, true
}
+24
View File
@@ -0,0 +1,24 @@
package handlers
import (
"encoding/json"
"net/http"
)
func WriteJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(payload)
}
func WriteText(writer http.ResponseWriter, status int, message string) {
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(status)
_, _ = writer.Write([]byte(message))
}
func DecodeJSON(request *http.Request, target any) error {
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(target)
}
+35
View File
@@ -0,0 +1,35 @@
package handlers
import (
"log"
"net/http"
"abuse_registration_poc/internal/models"
"abuse_registration_poc/internal/utils"
)
// Login mirrors the original API login handler shape: validate user credentials,
// generate a JWT, and return {message, token}.
func (h *Handler) Login(writer http.ResponseWriter, request *http.Request) {
var user models.ValidateUser
if err := DecodeJSON(request, &user); err != nil {
log.Printf("Error parsing request data: %v", err)
WriteJSON(writer, http.StatusBadRequest, map[string]string{"message": "Could not parse request data."})
return
}
if err := h.users.ValidateCredentials(&user); err != nil {
log.Printf("Error validating credentials: %v", err)
WriteJSON(writer, http.StatusUnauthorized, map[string]string{"message": "Could not authenticate user."})
return
}
token, err := utils.GenerateToken(user.UserName, user.ID, user.Role)
if err != nil {
log.Printf("Error generating token: %v", err)
WriteJSON(writer, http.StatusInternalServerError, map[string]string{"message": "Could not authenticate user."})
return
}
WriteJSON(writer, http.StatusOK, map[string]string{"message": "Login successful!", "token": token})
}
+43
View File
@@ -0,0 +1,43 @@
package middlewares
import (
"context"
"net/http"
"strings"
"abuse_registration_poc/internal/handlers"
"abuse_registration_poc/internal/utils"
)
type contextKey string
const (
RoleContextKey contextKey = "role"
UserIDContextKey contextKey = "userId"
)
func Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
token := strings.TrimSpace(request.Header.Get("Authorization"))
if token == "" {
handlers.WriteJSON(writer, http.StatusUnauthorized, map[string]string{"message": "Not authorized."})
return
}
token = strings.TrimPrefix(token, "Bearer ")
userID, role, err := utils.VerifyToken(token)
if err != nil {
handlers.WriteJSON(writer, http.StatusUnauthorized, map[string]string{"message": "Not authorized."})
return
}
ctx := context.WithValue(request.Context(), RoleContextKey, role)
ctx = context.WithValue(ctx, UserIDContextKey, userID)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func RoleFromContext(ctx context.Context) (string, bool) {
role, ok := ctx.Value(RoleContextKey).(string)
return role, ok
}
+16
View File
@@ -0,0 +1,16 @@
package middlewares
import "net/http"
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Access-Control-Allow-Origin", "*")
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
+65
View File
@@ -0,0 +1,65 @@
package middlewares
import (
"net/http"
"abuse_registration_poc/internal/handlers"
"abuse_registration_poc/internal/models"
)
var routePermissions = map[string]map[string][]string{
"GET": {
"/api/v1/categories": {models.RoleReader, models.RoleAdmin},
"/api/v1/locations": {models.RoleReader, models.RoleAdmin},
"/api/v1/registrations": {models.RoleReader, models.RoleAdmin},
"/api/v1/registrations/:id": {models.RoleReader, models.RoleAdmin},
},
"POST": {
"/api/v1/registrations": {models.RoleAdmin},
"/api/v1/reset": {models.RoleAdmin},
},
"PUT": {
"/api/v1/registrations/:id": {models.RoleAdmin},
},
"DELETE": {
"/api/v1/registrations/:id": {models.RoleAdmin},
},
}
func DynamicAuthorize(routePattern string, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
role, exists := RoleFromContext(request.Context())
if !exists {
handlers.WriteJSON(writer, http.StatusForbidden, map[string]string{"message": "Access denied."})
return
}
if !hasPermission(role, request.Method, routePattern) {
handlers.WriteJSON(writer, http.StatusForbidden, map[string]string{"message": "Access denied to this resource."})
return
}
next.ServeHTTP(writer, request)
})
}
func Protected(routePattern string, next http.Handler) http.Handler {
return Authenticate(DynamicAuthorize(routePattern, next))
}
func hasPermission(role string, method string, path string) bool {
methodPermissions, ok := routePermissions[method]
if !ok {
return false
}
roles, ok := methodPermissions[path]
if !ok {
return false
}
for _, allowed := range roles {
if allowed == role {
return true
}
}
return false
}
+149
View File
@@ -0,0 +1,149 @@
package models
import (
"errors"
"strings"
"time"
)
const (
RoleReader = "Reader"
RoleAdmin = "Admin"
)
var AbuseCategories = []string{
"physical",
"psychological",
"sexual",
"economic",
"material",
"digital",
"stalking",
"threats",
"honor_related",
}
var Genders = []string{"female", "male", "non_binary", "unknown"}
var Statuses = []string{"new", "open", "referred", "closed"}
// FaroeLocations is the fixed set of accepted location values for this POC.
var FaroeLocations = []string{
"Akrar", "Argir", "Ánirnar", "Árnafjørður", "Bøur", "Dalur", "Depil", "Eiði",
"Elduvík", "Fámjin", "Froðba", "Fuglafjørður", "Funningsfjørður", "Funningur",
"Gásadalur", "Gjógv", "Glyvrar", "Gøtugjógv", "Haldórsvík", "Haraldssund",
"Hattarvík", "Hellurnar", "Hestur", "Hov", "Hoyvík", "Hósvík", "Húsar",
"Húsavík", "Hvalba", "Hvalvík", "Hvannasund", "Hvítanes", "Innan Glyvur",
"Kaldbak", "Kaldbaksbotnur", "Kirkja", "Kirkjubøur", "Klaksvík", "Kolbeinagjógv",
"Kollafjørður", "Koltur", "Kunoy", "Kvívík", "Lamba", "Lambareiði", "Langasandur",
"Leirvík", "Leynar", "Ljósá", "Lopra", "Miðvágur", "Mikladalur", "Mjørkadalur",
"Morskranes", "Múli", "Mykines", "Nes", "Nesvík", "Nólsoy", "Norðdepil",
"Norðoyri", "Norðradalur", "Norðragøta", "Norðskáli", "Norðtoftir", "Oyndarfjørður",
"Oyrarbakki", "Oyrareingir", "Oyri", "Porkeri", "Rituvík", "Runavík", "Saksun",
"Saltangará", "Saltnes", "Sandavágur", "Sandur", "Sandvík", "Selatrað", "Signabøur",
"Skarvanes", "Skála", "Skálafjørður/Skálabotnur", "Skálafjørður (Eysturkommuna)",
"Skálavík", "Skipanes", "Skopun", "Skúgvoy", "Skælingur", "Stóra Dímun",
"Strendur", "Streymnes", "Stykkið", "Sumba", "Sund", "Svínáir", "Svínoy",
"Syðradalur (Kalsoy)", "Syðradalur (Streymoy)", "Syðrugøta", "Søldarfjørður",
"Sørvágur", "Tjørnuvík", "Toftir", "Tórshavn", "Trongisvágur", "Trøllanes",
"Tvøroyri", "Undir Gøtueiði", "Vatnsoyrar", "Vágur", "Válur", "Velbastaður",
"Vestmanna", "Viðareiði", "Víkarbyrgi", "Æðuvík", "Ørðavík/Øravík", "Øravíkarlíð",
}
type Registration struct {
ID string `json:"id"`
RegisteredAt string `json:"registered_at"`
Gender string `json:"gender"`
Location string `json:"location"`
AbuseType string `json:"abuse_type"`
Status string `json:"status"`
}
type RegistrationFilter struct {
Location string
AbuseType string
Gender string
Status string
Search string
From time.Time
To time.Time
HasFrom bool
HasTo bool
Limit int
Offset int
}
func NormalizeRegistration(input *Registration) error {
registeredAt := strings.TrimSpace(input.RegisteredAt)
if registeredAt == "" {
input.RegisteredAt = time.Now().UTC().Format(time.RFC3339)
} else {
parsed, err := ParseInputTime(registeredAt)
if err != nil {
return errors.New("registered_at must be RFC3339 or YYYY-MM-DD")
}
input.RegisteredAt = parsed.UTC().Format(time.RFC3339)
}
gender := strings.ToLower(strings.TrimSpace(input.Gender))
if gender == "" {
gender = "unknown"
}
if !ContainsFold(Genders, gender) {
return errors.New("gender must be one of: female, male, non_binary, unknown")
}
input.Gender = gender
location, ok := CanonicalLocation(input.Location)
if !ok {
return errors.New("location must be one of the allowed Faroese towns/villages")
}
input.Location = location
abuseType := strings.ToLower(strings.TrimSpace(input.AbuseType))
if !ContainsFold(AbuseCategories, abuseType) {
return errors.New("abuse_type must be one of the allowed categories")
}
input.AbuseType = abuseType
status := strings.ToLower(strings.TrimSpace(input.Status))
if status == "" {
status = "new"
}
if !ContainsFold(Statuses, status) {
return errors.New("status must be one of: new, open, referred, closed")
}
input.Status = status
return nil
}
func ParseInputTime(value string) (time.Time, error) {
value = strings.TrimSpace(value)
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
if parsed, err := time.Parse("2006-01-02", value); err == nil {
return parsed, nil
}
return time.Time{}, errors.New("invalid time")
}
func CanonicalLocation(value string) (string, bool) {
needle := strings.ToLower(strings.TrimSpace(value))
for _, location := range FaroeLocations {
if strings.ToLower(location) == needle {
return location, true
}
}
return "", false
}
func ContainsFold(values []string, needle string) bool {
needle = strings.ToLower(strings.TrimSpace(needle))
for _, value := range values {
if strings.ToLower(strings.TrimSpace(value)) == needle {
return true
}
}
return false
}
+15
View File
@@ -0,0 +1,15 @@
package models
type ValidateUser struct {
ID int64 `json:"id"`
UserName string `json:"user_name" binding:"required"`
Password string `json:"password" binding:"required"`
Role string `json:"role"`
}
type User struct {
ID int64 `json:"id"`
UserName string `json:"user_name"`
Password string `json:"-"`
Role string `json:"role"`
}
+92
View File
@@ -0,0 +1,92 @@
package routes
import (
"log"
"net/http"
"strings"
"abuse_registration_poc/internal/handlers"
"abuse_registration_poc/internal/middlewares"
"abuse_registration_poc/internal/web"
)
func RegisterRoutes(h *handlers.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
if (request.Method == http.MethodGet || request.Method == http.MethodHead) && request.URL.Path == "/" {
h.Index(writer, request)
return
}
if strings.HasPrefix(request.URL.Path, "/api/") {
handlers.WriteJSON(writer, http.StatusNotFound, map[string]string{"message": "Not found."})
return
}
handlers.WriteText(writer, http.StatusNotFound, "Not found. This POC only serves /, /login, /health, /demo/registrations, and /api/v1/...\n")
})
mux.HandleFunc("/health", method(http.MethodGet, h.Health))
mux.HandleFunc("/login", method(http.MethodPost, h.Login))
mux.HandleFunc("/demo/registrations", method(http.MethodGet, h.DemoRegistrations))
staticFS, err := web.StaticFS()
if err != nil {
log.Fatalf("failed to initialize embedded static assets: %v", err)
}
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
mux.HandleFunc("/favicon.ico", func(writer http.ResponseWriter, request *http.Request) {
favicon, err := web.Favicon()
if err != nil {
handlers.WriteText(writer, http.StatusNotFound, "Not found.\n")
return
}
writer.Header().Set("Content-Type", "image/x-icon")
_, _ = writer.Write(favicon)
})
mux.Handle("/api/v1/categories", middlewares.Protected("/api/v1/categories", http.HandlerFunc(method(http.MethodGet, h.Categories))))
mux.Handle("/api/v1/locations", middlewares.Protected("/api/v1/locations", http.HandlerFunc(method(http.MethodGet, h.Locations))))
mux.Handle("/api/v1/reset", middlewares.Protected("/api/v1/reset", http.HandlerFunc(method(http.MethodPost, h.ResetRegistrations))))
mux.Handle("/api/v1/registrations", middlewares.Protected("/api/v1/registrations", http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
h.ListRegistrations(writer, request)
case http.MethodPost:
h.CreateRegistration(writer, request)
default:
methodNotAllowed(writer)
}
})))
mux.Handle("/api/v1/registrations/", middlewares.Protected("/api/v1/registrations/:id", http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
id := strings.TrimPrefix(request.URL.Path, "/api/v1/registrations/")
if id == "" || strings.Contains(id, "/") {
handlers.WriteJSON(writer, http.StatusNotFound, map[string]string{"message": "Not found."})
return
}
switch request.Method {
case http.MethodGet:
h.GetRegistration(writer, request, id)
case http.MethodPut:
h.UpdateRegistration(writer, request, id)
case http.MethodDelete:
h.DeleteRegistration(writer, request, id)
default:
methodNotAllowed(writer)
}
})))
return middlewares.CORS(mux)
}
func method(expected string, handler http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if request.Method != expected {
methodNotAllowed(writer)
return
}
handler(writer, request)
}
}
func methodNotAllowed(writer http.ResponseWriter) {
handlers.WriteJSON(writer, http.StatusMethodNotAllowed, map[string]string{"message": "Method not allowed."})
}
+209
View File
@@ -0,0 +1,209 @@
package store
import (
"math/rand"
"sort"
"strings"
"sync"
"time"
"abuse_registration_poc/internal/models"
"abuse_registration_poc/internal/utils"
)
type MemoryStore struct {
mu sync.RWMutex
rows []models.Registration
resetInterval time.Duration
lastReset time.Time
nextReset time.Time
baseSize int
}
func NewMemoryStore(baseSize int, resetInterval time.Duration) *MemoryStore {
if baseSize < 1 {
baseSize = 546
}
if resetInterval < time.Second {
resetInterval = 10 * time.Minute
}
s := &MemoryStore{baseSize: baseSize, resetInterval: resetInterval}
s.Reset()
return s
}
func (s *MemoryStore) ResetInterval() time.Duration {
return s.resetInterval
}
func (s *MemoryStore) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
s.rows = generateBaseDataset(s.baseSize)
s.lastReset = now
s.nextReset = now.Add(s.resetInterval)
}
func (s *MemoryStore) Snapshot() ([]models.Registration, time.Time, time.Time) {
s.resetIfNeeded()
s.mu.RLock()
defer s.mu.RUnlock()
rows := make([]models.Registration, len(s.rows))
copy(rows, s.rows)
return rows, s.lastReset, s.nextReset
}
func (s *MemoryStore) List(filter models.RegistrationFilter) []models.Registration {
rows, _, _ := s.Snapshot()
return applyFilter(rows, filter)
}
func (s *MemoryStore) Get(id string) (models.Registration, bool) {
s.resetIfNeeded()
s.mu.RLock()
defer s.mu.RUnlock()
for _, row := range s.rows {
if row.ID == id {
return row, true
}
}
return models.Registration{}, false
}
func (s *MemoryStore) Create(input models.Registration) (models.Registration, error) {
s.resetIfNeeded()
if err := models.NormalizeRegistration(&input); err != nil {
return models.Registration{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
input.ID = utils.NewUUID()
s.rows = append(s.rows, input)
return input, nil
}
func (s *MemoryStore) Replace(id string, input models.Registration) (models.Registration, bool, error) {
s.resetIfNeeded()
if err := models.NormalizeRegistration(&input); err != nil {
return models.Registration{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.rows {
if s.rows[i].ID == id {
input.ID = id
s.rows[i] = input
return input, true, nil
}
}
return models.Registration{}, false, nil
}
func (s *MemoryStore) Delete(id string) bool {
s.resetIfNeeded()
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.rows {
if s.rows[i].ID == id {
s.rows = append(s.rows[:i], s.rows[i+1:]...)
return true
}
}
return false
}
func (s *MemoryStore) resetIfNeeded() {
s.mu.RLock()
due := time.Now().UTC().After(s.nextReset)
s.mu.RUnlock()
if due {
s.Reset()
}
}
func applyFilter(rows []models.Registration, filter models.RegistrationFilter) []models.Registration {
location := strings.ToLower(strings.TrimSpace(filter.Location))
abuseType := strings.ToLower(strings.TrimSpace(filter.AbuseType))
gender := strings.ToLower(strings.TrimSpace(filter.Gender))
status := strings.ToLower(strings.TrimSpace(filter.Status))
search := strings.ToLower(strings.TrimSpace(filter.Search))
out := make([]models.Registration, 0, len(rows))
for _, row := range rows {
if location != "" && strings.ToLower(row.Location) != location {
continue
}
if abuseType != "" && strings.ToLower(row.AbuseType) != abuseType {
continue
}
if gender != "" && strings.ToLower(row.Gender) != gender {
continue
}
if status != "" && strings.ToLower(row.Status) != status {
continue
}
if filter.HasFrom || filter.HasTo {
registeredAt, err := time.Parse(time.RFC3339, row.RegisteredAt)
if err != nil {
continue
}
if filter.HasFrom && registeredAt.Before(filter.From) {
continue
}
if filter.HasTo && registeredAt.After(filter.To) {
continue
}
}
if search != "" && !strings.Contains(strings.ToLower(row.ID+" "+row.Location+" "+row.AbuseType+" "+row.Gender+" "+row.Status), search) {
continue
}
out = append(out, row)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].RegisteredAt < out[j].RegisteredAt })
if filter.Offset > 0 {
if filter.Offset >= len(out) {
return []models.Registration{}
}
out = out[filter.Offset:]
}
if filter.Limit > 0 && filter.Limit < len(out) {
out = out[:filter.Limit]
}
return out
}
func generateBaseDataset(count int) []models.Registration {
seed := rand.New(rand.NewSource(19881991))
locations := models.FaroeLocations
rows := make([]models.Registration, 0, count)
for i := 0; i < count; i++ {
location := locations[i%len(locations)]
if i >= len(locations) {
location = locations[seed.Intn(len(locations))]
}
rows = append(rows, models.Registration{
ID: utils.NewUUID(),
RegisteredAt: randomRegistrationTime(seed).Format(time.RFC3339),
Gender: models.Genders[seed.Intn(len(models.Genders))],
Location: location,
AbuseType: models.AbuseCategories[seed.Intn(len(models.AbuseCategories))],
Status: models.Statuses[seed.Intn(len(models.Statuses))],
})
}
return rows
}
func randomRegistrationTime(seed *rand.Rand) time.Time {
start := time.Date(1988, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(1991, 12, 31, 23, 59, 59, 0, time.UTC)
span := end.Unix() - start.Unix()
return time.Unix(start.Unix()+seed.Int63n(span), 0).UTC()
}
+98
View File
@@ -0,0 +1,98 @@
package utils
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"abuse_registration_poc/internal/config"
)
func getAPIKey() (string, error) {
apiKey := config.ApiKey
if apiKey == "" {
return "", fmt.Errorf("API key is not set in the configuration")
}
return apiKey, nil
}
func GenerateToken(userName string, userID int64, role string) (string, error) {
apiKey, err := getAPIKey()
if err != nil {
return "", err
}
header := map[string]string{"alg": "HS256", "typ": "JWT"}
claims := map[string]any{
"userName": userName,
"userId": userID,
"role": role,
"exp": time.Now().UTC().Add(2 * time.Hour).Unix(),
}
headerBytes, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsBytes, err := json.Marshal(claims)
if err != nil {
return "", err
}
unsigned := base64.RawURLEncoding.EncodeToString(headerBytes) + "." + base64.RawURLEncoding.EncodeToString(claimsBytes)
return unsigned + "." + sign(unsigned, apiKey), nil
}
func VerifyToken(token string) (int64, string, error) {
apiKey, err := getAPIKey()
if err != nil {
return 0, "", err
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return 0, "", errors.New("invalid token")
}
unsigned := parts[0] + "." + parts[1]
expected := sign(unsigned, apiKey)
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
return 0, "", errors.New("invalid signature")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return 0, "", err
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return 0, "", err
}
role, ok := claims["role"].(string)
if !ok {
return 0, "", errors.New("invalid role claim")
}
userIDFloat, ok := claims["userId"].(float64)
if !ok {
return 0, "", errors.New("invalid userId claim")
}
exp, ok := claims["exp"].(float64)
if !ok || time.Now().UTC().Unix() > int64(exp) {
return 0, "", errors.New("token expired")
}
return int64(userIDFloat), role, nil
}
func sign(unsigned string, apiKey string) string {
mac := hmac.New(sha256.New, []byte(apiKey))
mac.Write([]byte(unsigned))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
+16
View File
@@ -0,0 +1,16 @@
package utils
import (
"crypto/rand"
"fmt"
)
func NewUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "00000000-0000-4000-8000-000000000000"
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+400
View File
@@ -0,0 +1,400 @@
/* Abuse registration POC — styled to match the FLÓ/zeitkort POC shell. */
*, *::before, *::after { box-sizing: border-box; }
:root,
[data-theme="light"] {
color-scheme: light;
--bg: #f4f1ec;
--fg: #1a1d22;
--muted: #595f68;
--dim: #9299a2;
--border: rgba(26, 29, 34, 0.12);
--border-soft: rgba(26, 29, 34, 0.06);
--dotted: rgba(26, 29, 34, 0.12);
--field-bg: rgba(26, 29, 34, 0.03);
--field-border: rgba(26, 29, 34, 0.18);
--btn-bg: rgba(26, 29, 34, 0.05);
--btn-bg-hover: rgba(26, 29, 34, 0.09);
--btn-border: rgba(26, 29, 34, 0.18);
--header-border: rgba(26, 29, 34, 0.12);
--link: #2c5a7a;
--accent: #2c5a7a;
--nav-bg: rgba(244, 241, 236, 0.6);
--bg-panel: rgba(255, 253, 250, 0.72);
--notice-fg: #2a6e2a;
--notice-bg: rgba(42, 110, 42, 0.06);
--notice-border: rgba(42, 110, 42, 0.25);
--error-fg: #a03030;
--error-bg: rgba(160, 48, 48, 0.06);
--error-border: rgba(160, 48, 48, 0.25);
--shadow: rgba(0, 0, 0, 0.12);
--toggle-bg: linear-gradient(135deg, #07070d 0%, #64dcff 100%);
--toggle-border: rgba(26, 29, 34, 0.2);
--toggle-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
[data-theme="tokyo"] {
color-scheme: dark;
--bg: #07070d;
--fg: #e8f4ff;
--muted: #a3b3cc;
--dim: #7886a0;
--border: rgba(100, 220, 255, 0.12);
--border-soft: rgba(100, 220, 255, 0.06);
--dotted: rgba(100, 220, 255, 0.1);
--field-bg: rgba(100, 220, 255, 0.04);
--field-border: rgba(100, 220, 255, 0.15);
--btn-bg: rgba(100, 220, 255, 0.06);
--btn-bg-hover: rgba(100, 220, 255, 0.1);
--btn-border: rgba(100, 220, 255, 0.18);
--header-border: rgba(100, 220, 255, 0.12);
--link: #64dcff;
--accent: #64dcff;
--nav-bg: rgba(7, 7, 13, 0.65);
--bg-panel: rgba(15, 16, 28, 0.72);
--notice-fg: #8fe08f;
--notice-bg: rgba(143, 224, 143, 0.06);
--notice-border: rgba(143, 224, 143, 0.25);
--error-fg: #ff9b9b;
--error-bg: rgba(255, 155, 155, 0.06);
--error-border: rgba(255, 155, 155, 0.2);
--shadow: rgba(0, 0, 0, 0.5);
--toggle-bg: #f4f1ec;
--toggle-border: rgba(100, 220, 255, 0.4);
--toggle-shadow: 0 0 10px rgba(100, 220, 255, 0.5), 0 0 25px rgba(100, 220, 255, 0.3);
}
html, body { max-width: 100%; overflow-x: hidden; }
body {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
font-size: 0.88rem;
font-weight: 300;
line-height: 1.55;
color: var(--fg);
background: var(--bg);
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: background 0.3s ease, color 0.3s ease;
}
main {
overflow-wrap: break-word;
padding: 16px;
max-width: 1040px;
margin: 0 auto;
width: 100%;
flex: 1;
}
header {
border-bottom: 1px solid var(--header-border);
padding: 1rem 1.5rem;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
background: var(--nav-bg);
backdrop-filter: blur(12px) saturate(120%);
-webkit-backdrop-filter: blur(12px) saturate(120%);
position: sticky;
top: 0;
z-index: 20;
transition: background 0.3s ease;
}
.back-link {
font-weight: 400;
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
white-space: nowrap;
color: var(--muted);
text-decoration: none;
padding: 0.35rem 0.6rem;
border: 1px solid var(--border);
border-radius: 2px;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.back-link:hover { color: var(--fg); border-color: var(--muted); background: var(--bg-panel); }
.brand {
justify-self: center;
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.02em;
color: var(--fg);
text-decoration: none;
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
white-space: nowrap;
}
.brand-sep { font-weight: 300; color: var(--dim); }
.brand-sub {
font-weight: 400;
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
[data-theme="tokyo"] .brand { color: #e8f4ff; text-shadow: 0 0 8px rgba(100, 220, 255, 0.3); }
footer {
padding: 0.9rem 1.5rem;
color: var(--muted);
font-size: 0.68rem;
letter-spacing: 0.05em;
border-top: 1px solid var(--border-soft);
background: var(--nav-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
}
footer a { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
footer a:hover { color: var(--accent); }
.footer-icon { color: var(--muted); text-decoration: none; display: flex; align-items: center; border-bottom: none; transition: color 0.25s ease; }
.footer-icon:hover { color: var(--accent); border-bottom: none; }
h1, h2, h3 { font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; font-weight: 700; letter-spacing: -0.02em; }
h1 { margin: 0 0 0.9rem; font-size: clamp(1.35rem, 4vw, 2rem); }
h2 { margin: 0 0 0.9rem; font-size: 1.1rem; }
h3 { margin: 1rem 0 0.4rem; font-size: 0.86rem; color: var(--fg); }
.workflow-content h3:first-child { margin-top: 0; }
p { margin: 0 0 0.8rem; }
a { color: var(--link); }
code { font-family: inherit; font-size: 0.86em; }
.muted { color: var(--muted); font-size: 0.78rem; }
.intro-card,
.section-block {
border: 1px solid var(--border);
border-radius: 3px;
background: var(--field-bg);
padding: 1.2rem 1.4rem;
margin: 0 0 14px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.columns { display: flex; gap: 16px; flex-wrap: wrap; }
.col { flex: 1 1 340px; min-width: 0; }
fieldset {
border: 1px solid var(--border);
margin: 0;
padding: 10px 12px;
min-width: 0;
border-radius: 3px;
background: var(--field-bg);
transition: border-color 0.3s ease, background 0.3s ease;
}
legend {
font-weight: 500;
padding: 0 6px;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
pre {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--border-soft);
border-radius: 3px;
background: rgba(26, 29, 34, 0.04);
margin: 0.8rem 0 0;
padding: 0.85rem 0.95rem;
font-size: 0.78rem;
line-height: 1.45;
}
[data-theme="tokyo"] pre { background: rgba(100, 220, 255, 0.04); }
.harmonium {
display: grid;
gap: 10px;
margin-top: 1rem;
}
.workflow-panel {
border: 1px solid var(--border);
border-radius: 3px;
background: var(--field-bg);
overflow: hidden;
}
.workflow-panel summary {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
cursor: pointer;
color: var(--fg);
border-bottom: 1px solid transparent;
user-select: none;
}
.workflow-panel summary::-webkit-details-marker { display: none; }
.workflow-panel summary::before {
content: '+';
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.2rem;
margin-right: 0.2rem;
border: 1px solid var(--border);
border-radius: 50%;
color: var(--muted);
flex: 0 0 auto;
}
.workflow-panel summary span:first-child { margin-right: auto; }
.workflow-panel[open] summary {
border-bottom-color: var(--border-soft);
background: var(--btn-bg);
}
.workflow-panel[open] summary::before { content: ''; color: var(--accent); }
.summary-hint {
color: var(--dim);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.workflow-content { padding: 1rem; }
.workflow-content pre:last-child { margin-bottom: 0; }
select, input[type=text], input[type=number], input:not([type]) {
font: inherit;
font-size: 0.85rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--field-border);
border-radius: 3px;
background: var(--field-bg);
color: var(--fg);
transition: border-color 0.2s;
outline: none;
width: 100%;
}
[data-theme="tokyo"] select,
[data-theme="tokyo"] option { background: #0f1018; color: #e8f4ff; }
select:focus, input:focus { border-color: var(--accent); }
label { display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 0.75rem; }
label input, label select { margin-top: 0.25rem; }
button {
font: inherit;
font-size: 0.82rem;
font-weight: 500;
padding: 0.3rem 0.7rem;
border: 1px solid var(--btn-border);
border-radius: 3px;
background: var(--btn-bg);
color: var(--fg);
cursor: pointer;
letter-spacing: 0.02em;
transition: background 0.15s, border-color 0.15s;
}
button:hover { background: var(--btn-bg-hover); }
.inline { display: inline-flex; gap: 6px; align-items: center; margin: 0; flex-wrap: wrap; }
.data-browser {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.filter-form {
border: 1px solid var(--border);
border-radius: 3px;
background: var(--field-bg);
padding: 12px;
position: sticky;
top: 66px;
}
.json-pane { min-width: 0; }
.json-output { min-height: 360px; margin-top: 0.4rem; }
.reset-banner {
position: sticky;
top: 52px;
z-index: 18;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 8px 16px;
border-bottom: 1px solid var(--error-border);
background: var(--error-bg);
color: var(--error-fg);
text-align: center;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.reset-label { font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.72rem; }
.reset-countdown {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
font-size: clamp(1.5rem, 6vw, 2.5rem);
font-weight: 700;
line-height: 1;
padding: 2px 10px;
border: 1px solid currentColor;
border-radius: 3px;
background: var(--bg);
}
.reset-note { flex-basis: 100%; font-size: 0.72rem; font-weight: 500; letter-spacing: 0.04em; }
.theme-toggle {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--toggle-border);
cursor: pointer;
padding: 0;
z-index: 30;
transition: all 0.3s ease;
box-shadow: var(--toggle-shadow);
background: var(--toggle-bg);
}
.theme-toggle:hover { transform: scale(1.08); }
@media (min-width: 641px) {
.reset-banner {
position: fixed;
left: 1rem;
bottom: 1rem;
top: auto;
justify-content: flex-start;
gap: 8px;
max-width: calc(100vw - 2rem);
padding: 6px 10px;
border: 1px solid var(--error-border);
border-radius: 999px;
font-size: 0.7rem;
text-align: left;
}
.reset-countdown { font-size: 0.9rem; padding: 1px 8px; border-radius: 999px; }
.reset-note { display: none; }
}
@media (max-width: 760px) {
header { padding: 0.7rem 0.9rem; gap: 0.6rem; }
.back-link { font-size: 0.58rem; padding: 0.25rem 0.4rem; letter-spacing: 0.08em; }
.brand { font-size: 0.9rem; }
main { padding: 12px; }
footer { font-size: 0.62rem; padding: 0.7rem 0.9rem; }
.theme-toggle { width: 20px; height: 20px; bottom: 1rem; right: 0.9rem; }
.reset-banner { top: 44px; }
.data-browser { grid-template-columns: 1fr; }
.filter-form { position: static; }
}
+91
View File
@@ -0,0 +1,91 @@
(() => {
const filterForm = document.getElementById('filter-form');
const dataStatus = document.getElementById('data-status');
const jsonData = document.getElementById('json-data');
const resetFilters = document.getElementById('reset-filters');
const countdown = document.getElementById('reset-countdown');
function setStatus(text) {
if (dataStatus) dataStatus.textContent = text;
}
function valueOf(form, name) {
return String(form.get(name) || '').trim();
}
function buildQuery() {
const form = new FormData(filterForm);
const params = new URLSearchParams();
['location', 'abuse_type', 'gender', 'status', 'from', 'to', 'search', 'limit'].forEach((key) => {
const value = valueOf(form, key);
if (value) params.set(key, value);
});
return params.toString();
}
async function renderData(event) {
if (event) event.preventDefault();
if (!filterForm || !jsonData) return;
const query = buildQuery();
const url = query ? `/demo/registrations?${query}` : '/demo/registrations';
setStatus('Loading data…');
try {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const rows = await response.json();
jsonData.textContent = JSON.stringify(rows, null, 2);
setStatus(`Showing ${rows.length} row${rows.length === 1 ? '' : 's'} from ${url}.`);
} catch (err) {
jsonData.textContent = '[]';
setStatus(`Could not load demo data: ${err.message}`);
}
}
function renderCountdown() {
if (!countdown) return;
const resetAt = new Date(countdown.dataset.resetAt);
if (Number.isNaN(resetAt.getTime())) return;
function tick() {
const remainingMs = resetAt.getTime() - Date.now();
if (remainingMs <= 0) {
countdown.textContent = 'resetting…';
window.setTimeout(() => window.location.reload(), 1200);
return;
}
const seconds = Math.floor(remainingMs / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
countdown.textContent = `${minutes}m ${String(rest).padStart(2, '0')}s`;
window.setTimeout(tick, 1000);
}
tick();
}
function setupSingleOpenAccordions() {
document.querySelectorAll('[data-single-open]').forEach((group) => {
const panels = Array.from(group.querySelectorAll('details'));
panels.forEach((panel) => {
panel.addEventListener('toggle', () => {
if (!panel.open) return;
panels.forEach((other) => {
if (other !== panel) other.open = false;
});
});
});
});
}
if (filterForm) filterForm.addEventListener('submit', renderData);
if (resetFilters) resetFilters.addEventListener('click', () => {
filterForm.reset();
renderData();
});
setupSingleOpenAccordions();
renderCountdown();
renderData();
})();
+23
View File
@@ -0,0 +1,23 @@
(function () {
function currentSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light';
}
var btn = document.getElementById('theme-toggle');
if (btn) {
btn.addEventListener('click', function () {
var current = document.documentElement.getAttribute('data-theme') || 'light';
var next = current === 'light' ? 'tokyo' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('poc-theme', next);
window.dispatchEvent(new Event('theme-change'));
});
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if (!localStorage.getItem('poc-theme')) {
document.documentElement.setAttribute('data-theme', currentSystemTheme());
window.dispatchEvent(new Event('theme-change'));
}
});
})();
+230
View File
@@ -0,0 +1,230 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Registration API POC — FLÓ</title>
<script>
(function () {
var stored = localStorage.getItem('poc-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
</head>
<body>
<header>
<a href="https://fló.fo" class="back-link">← FLÓ.FO</a>
<span class="brand">FLÓ <span class="brand-sep">/</span> <span class="brand-sub">API POC</span></span>
<span aria-hidden="true"></span>
</header>
<div class="reset-banner" role="status" aria-live="polite">
<span class="reset-label">POC reset in</span>
<span id="reset-countdown" class="reset-countdown" data-reset-at="{{ .NextReset }}">--:--</span>
<span class="reset-note">All in-memory data resets every 10 minutes.</span>
</div>
<main id="main">
<section class="intro-card">
<h1>Abuse Registration API Example</h1>
<p>This API uses JWT for authentication and authorization. Data is stored in memory.</p>
</section>
<section class="columns section-block">
<div class="col">
<fieldset>
<legend>Reader user</legend>
<p><code>reader</code> / <code>reader-password</code></p>
<p class="muted">Can read protected endpoints and use filters.</p>
</fieldset>
</div>
<div class="col">
<fieldset>
<legend>CRUD user</legend>
<p><code>admin</code> / <code>admin-password</code></p>
<p class="muted">Can create, read, update with PUT, delete, and reset the sample dataset.</p>
</fieldset>
</div>
</section>
<section class="section-block workflow-section">
<h2>Terminal workflows</h2>
<p class="muted">These examples are terminal only. You can translate it to postman if you want to.</p>
<div class="harmonium" data-single-open>
<details class="workflow-panel" open>
<summary>
<span>Linux / curl</span>
</summary>
<div class="workflow-content">
<h3>Reader user — filtered reads</h3>
<pre><code>READER_TOKEN=$(curl -s -X POST http://localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"user_name":"reader","password":"reader-password"}' \
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
curl -s 'http://localhost:8080/api/v1/registrations?location=Tórshavn&limit=5' \
-H "Authorization: $READER_TOKEN"
curl -s 'http://localhost:8080/api/v1/registrations?gender=female&abuse_type=psychological&status=open&limit=10' \
-H "Authorization: $READER_TOKEN"
curl -s 'http://localhost:8080/api/v1/registrations?from=1989-01-01&to=1990-01-01&offset=20&limit=10' \
-H "Authorization: $READER_TOKEN"
curl -s 'http://localhost:8080/api/v1/locations' \
-H "Authorization: $READER_TOKEN"</code></pre>
<h3>CRUD user — create, update, delete</h3>
<pre><code>ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"user_name":"admin","password":"admin-password"}' \
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
CREATED_ID=$(curl -s -X POST http://localhost:8080/api/v1/registrations \
-H "Authorization: $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"gender":"unknown","location":"Tórshavn","abuse_type":"psychological","status":"new"}' \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
curl -s -X PUT "http://localhost:8080/api/v1/registrations/$CREATED_ID" \
-H "Authorization: $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"gender":"female","location":"Skopun","abuse_type":"digital","status":"referred"}'
curl -s -X DELETE "http://localhost:8080/api/v1/registrations/$CREATED_ID" \
-H "Authorization: $ADMIN_TOKEN"</code></pre>
</div>
</details>
<details class="workflow-panel">
<summary>
<span>Windows / PowerShell</span>
</summary>
<div class="workflow-content">
<h3>Reader user — filtered reads</h3>
<pre><code>$reader = Invoke-RestMethod &#96;
-Method Post &#96;
-Uri "http://localhost:8080/login" &#96;
-ContentType "application/json" &#96;
-Body '{"user_name":"reader","password":"reader-password"}'
$READER_TOKEN = $reader.token
Invoke-RestMethod &#96;
-Uri "http://localhost:8080/api/v1/registrations?location=Tórshavn&limit=5" &#96;
-Headers @{Authorization=$READER_TOKEN}
Invoke-RestMethod &#96;
-Uri "http://localhost:8080/api/v1/registrations?gender=female&abuse_type=psychological&status=open&limit=10" &#96;
-Headers @{Authorization=$READER_TOKEN}
Invoke-RestMethod &#96;
-Uri "http://localhost:8080/api/v1/registrations?from=1989-01-01&to=1990-01-01&offset=20&limit=10" &#96;
-Headers @{Authorization=$READER_TOKEN}
Invoke-RestMethod &#96;
-Uri "http://localhost:8080/api/v1/locations" &#96;
-Headers @{Authorization=$READER_TOKEN}</code></pre>
<h3>CRUD user — create, update, delete</h3>
<pre><code>$admin = Invoke-RestMethod &#96;
-Method Post &#96;
-Uri "http://localhost:8080/login" &#96;
-ContentType "application/json" &#96;
-Body '{"user_name":"admin","password":"admin-password"}'
$ADMIN_TOKEN = $admin.token
$created = Invoke-RestMethod &#96;
-Method Post &#96;
-Uri "http://localhost:8080/api/v1/registrations" &#96;
-Headers @{Authorization=$ADMIN_TOKEN} &#96;
-ContentType "application/json" &#96;
-Body '{"gender":"unknown","location":"Tórshavn","abuse_type":"psychological","status":"new"}'
$CREATED_ID = $created.id
Invoke-RestMethod &#96;
-Method Put &#96;
-Uri "http://localhost:8080/api/v1/registrations/$CREATED_ID" &#96;
-Headers @{Authorization=$ADMIN_TOKEN} &#96;
-ContentType "application/json" &#96;
-Body '{"gender":"female","location":"Skopun","abuse_type":"digital","status":"referred"}'
Invoke-RestMethod &#96;
-Method Delete &#96;
-Uri "http://localhost:8080/api/v1/registrations/$CREATED_ID" &#96;
-Headers @{Authorization=$ADMIN_TOKEN}</code></pre>
</div>
</details>
</div>
</section>
<section class="section-block data-section">
<h2>Current JSON data</h2>
<p class="muted">This viewer loads a public demo snapshot. The actual API routes under <code>/api/v1</code> still require a JWT.</p>
<div class="data-browser">
<form id="filter-form" class="filter-form">
<label>Location
<select name="location">
<option value="">all</option>
{{ range .Locations }}<option value="{{ . }}">{{ . }}</option>{{ end }}
</select>
</label>
<label>Abuse type
<select name="abuse_type">
<option value="">all</option>
{{ range .Categories }}<option value="{{ . }}">{{ . }}</option>{{ end }}
</select>
</label>
<label>Gender
<select name="gender">
<option value="">all</option>
{{ range .Genders }}<option value="{{ . }}">{{ . }}</option>{{ end }}
</select>
</label>
<label>Status
<select name="status">
<option value="">all</option>
{{ range .Statuses }}<option value="{{ . }}">{{ . }}</option>{{ end }}
</select>
</label>
<label>From <input name="from" placeholder="1988-01-01"></label>
<label>To <input name="to" placeholder="1991-12-31"></label>
<label>Search <input name="search" placeholder="uuid or field text"></label>
<label>Limit <input name="limit" type="number" min="0" value="50"></label>
<div class="inline form-buttons">
<button type="submit">Apply filters</button>
<button type="button" id="reset-filters">Clear</button>
</div>
</form>
<div class="json-pane">
<p id="data-status" class="muted">Loading data…</p>
<pre class="json-output"><code id="json-data">[]</code></pre>
</div>
</div>
</section>
</main>
<footer>
<span>&copy; <script>document.write(new Date().getFullYear())</script> <a href="https://fló.fo">FLÓ.FO</a> | ALL RIGHTS RESERVED | {{ .Version }}</span>
<a href="https://www.linkedin.com/in/bartal-l%C3%A6arsson-63793895/" target="_blank" rel="noopener" class="footer-icon" aria-label="LinkedIn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a>
</footer>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
<script src="/static/js/theme.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"embed"
"io/fs"
)
// Templates contains all server-rendered HTML templates.
//
//go:embed templates/index.html
var Templates embed.FS
// Assets contains every browser asset needed by the POC page.
// The production binary serves these files from memory, so the server does not
// depend on a static/ directory beside the executable.
//
//go:embed static/* static/css/* static/js/* favicon.ico
var Assets embed.FS
func StaticFS() (fs.FS, error) {
return fs.Sub(Assets, "static")
}
func Favicon() ([]byte, error) {
return Assets.ReadFile("favicon.ico")
}
Binary file not shown.