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
+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")
}