initial commit from original sev version
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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."})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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 `
|
||||||
|
-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}
|
||||||
|
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Uri "http://localhost:8080/api/v1/registrations?gender=female&abuse_type=psychological&status=open&limit=10" `
|
||||||
|
-Headers @{Authorization=$READER_TOKEN}
|
||||||
|
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Uri "http://localhost:8080/api/v1/registrations?from=1989-01-01&to=1990-01-01&offset=20&limit=10" `
|
||||||
|
-Headers @{Authorization=$READER_TOKEN}
|
||||||
|
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Uri "http://localhost:8080/api/v1/locations" `
|
||||||
|
-Headers @{Authorization=$READER_TOKEN}</code></pre>
|
||||||
|
|
||||||
|
<h3>CRUD user — create, update, delete</h3>
|
||||||
|
<pre><code>$admin = Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri "http://localhost:8080/login" `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body '{"user_name":"admin","password":"admin-password"}'
|
||||||
|
|
||||||
|
$ADMIN_TOKEN = $admin.token
|
||||||
|
|
||||||
|
$created = Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri "http://localhost:8080/api/v1/registrations" `
|
||||||
|
-Headers @{Authorization=$ADMIN_TOKEN} `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body '{"gender":"unknown","location":"Tórshavn","abuse_type":"psychological","status":"new"}'
|
||||||
|
|
||||||
|
$CREATED_ID = $created.id
|
||||||
|
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Put `
|
||||||
|
-Uri "http://localhost:8080/api/v1/registrations/$CREATED_ID" `
|
||||||
|
-Headers @{Authorization=$ADMIN_TOKEN} `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body '{"gender":"female","location":"Skopun","abuse_type":"digital","status":"referred"}'
|
||||||
|
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Delete `
|
||||||
|
-Uri "http://localhost:8080/api/v1/registrations/$CREATED_ID" `
|
||||||
|
-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>© <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>
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user