diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 000000000..ec272629c
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,18 @@
+FROM golang:1.23-alpine AS builder
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o prothetic-server .
+
+FROM alpine:latest
+WORKDIR /root/
+
+COPY --from=builder /app/prothetic-server .
+
+EXPOSE 8000
+
+CMD ["./prothetic-server"]
\ No newline at end of file
diff --git a/backend/config/config.go b/backend/config/config.go
new file mode 100644
index 000000000..0ddeb41ab
--- /dev/null
+++ b/backend/config/config.go
@@ -0,0 +1,20 @@
+package config
+
+import (
+ "github.com/caarlos0/env/v6"
+)
+
+type Config struct {
+ Issuer string `env:"ISSUER"`
+ ServerAddress string `env:"SERVER_ADDRESS" envDefault:":8000"`
+ AllowedOrigins string `env:"ALLOWED_ORIGINS" envDefault:"*"`
+}
+
+func Load() (*Config, error) {
+ cfg := &Config{}
+ err := env.Parse(cfg)
+ if err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 000000000..945cd2a76
--- /dev/null
+++ b/backend/go.mod
@@ -0,0 +1,17 @@
+module backend
+
+go 1.23.0
+
+toolchain go1.23.8
+
+require (
+ github.com/caarlos0/env/v6 v6.10.1
+ github.com/coreos/go-oidc/v3 v3.14.1
+ github.com/rs/cors v1.11.1
+)
+
+require (
+ github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+ golang.org/x/crypto v0.36.0 // indirect
+ golang.org/x/oauth2 v0.28.0 // indirect
+)
diff --git a/backend/handlers/reports.go b/backend/handlers/reports.go
new file mode 100644
index 000000000..5acae1efb
--- /dev/null
+++ b/backend/handlers/reports.go
@@ -0,0 +1,40 @@
+package handlers
+
+import (
+ "encoding/json"
+ "math/rand"
+ "net/http"
+ "time"
+)
+
+type Reading struct {
+ Timestamp time.Time `json:"timestamp"`
+ Sensor string `json:"sensor"`
+ Value float64 `json:"value"`
+}
+
+type Report struct {
+ GeneratedAt time.Time `json:"generated_at"`
+ Readings []Reading `json:"readings"`
+}
+
+var sensors = []string{"EMG1", "EMG2", "IMU1", "IMU2", "Battery"}
+
+func ReportsHandler(w http.ResponseWriter, _ *http.Request) {
+ now := time.Now()
+ readings := make([]Reading, len(sensors))
+ for i, sensor := range sensors {
+ readings[i] = Reading{
+ Timestamp: now.Add(-time.Duration(rand.Intn(1000)) * time.Millisecond),
+ Sensor: sensor,
+ Value: rand.Float64() * 100,
+ }
+ }
+ report := Report{
+ GeneratedAt: now,
+ Readings: readings,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(report)
+}
diff --git a/backend/main.go b/backend/main.go
new file mode 100644
index 000000000..3cad83558
--- /dev/null
+++ b/backend/main.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "time"
+
+ "backend/config"
+ "backend/handlers"
+ "backend/middleware"
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/rs/cors"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("failed to load config: %v", err)
+ }
+
+ ctx := context.Background()
+ provider, err := oidc.NewProvider(ctx, cfg.Issuer)
+ if err != nil {
+ log.Fatalf("failed to discover issuer: %v", err)
+ }
+ verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
+
+ mux := http.NewServeMux()
+ mux.Handle(
+ "/reports",
+ middleware.AuthMiddleware(verifier, "prothetic_user")(http.HandlerFunc(handlers.ReportsHandler)),
+ )
+
+ c := cors.New(cors.Options{
+ AllowedOrigins: []string{"http://localhost:3000"},
+ AllowedMethods: []string{"GET", "POST", "OPTIONS"},
+ AllowedHeaders: []string{"Authorization", "Content-Type"},
+ AllowCredentials: true,
+ })
+ handler := c.Handler(mux)
+
+ srv := &http.Server{
+ Addr: cfg.ServerAddress,
+ Handler: handler,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ }
+
+ log.Printf("server listening on %s", cfg.ServerAddress)
+ if err := srv.ListenAndServe(); err != nil {
+ log.Fatalf("server failed: %v", err)
+ }
+}
diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go
new file mode 100644
index 000000000..766fad4e2
--- /dev/null
+++ b/backend/middleware/auth.go
@@ -0,0 +1,56 @@
+package middleware
+
+import (
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+)
+
+func AuthMiddleware(verifier *oidc.IDTokenVerifier, requiredRole string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ auth := r.Header.Get("Authorization")
+ if auth == "" {
+ log.Printf("auth error: Authorization header missing")
+ http.Error(w, "Authorization header missing", http.StatusUnauthorized)
+ return
+ }
+ parts := strings.SplitN(auth, " ", 2)
+ if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
+ log.Printf("auth error: invalid header format: %q", auth)
+ http.Error(w, "Authorization header format must be Bearer {token}", http.StatusUnauthorized)
+ return
+ }
+ rawToken := parts[1]
+
+ idToken, err := verifier.Verify(r.Context(), rawToken)
+ if err != nil {
+ log.Printf("auth error: token verification failed: %v", err)
+ http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
+ return
+ }
+
+ var claims struct {
+ RealmAccess struct {
+ Roles []string `json:"roles"`
+ } `json:"realm_access"`
+ }
+ if err = idToken.Claims(&claims); err != nil {
+ log.Printf("auth error: claims parsing failed: %v", err)
+ http.Error(w, "Invalid token claims", http.StatusUnauthorized)
+ return
+ }
+
+ for _, role := range claims.RealmAccess.Roles {
+ if role == requiredRole {
+ next.ServeHTTP(w, r)
+ return
+ }
+ }
+ log.Printf("auth error: missing required role %q, roles: %v", requiredRole, claims.RealmAccess.Roles)
+ http.Error(w, "Forbidden: missing required role", http.StatusForbidden)
+ })
+ }
+}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index f21d8cf27..740dbccdc 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -20,11 +20,20 @@ services:
KC_DB_URL: jdbc:postgresql://keycloak_db:5432/keycloak_db
KC_DB_USERNAME: keycloak_user
KC_DB_PASSWORD: keycloak_password
- command:
+ KC_HEALTH_ENABLED: "true"
+ command:
- start-dev
- --import-realm
- volumes:
- - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json
+
+ healthcheck:
+ test: [ "CMD-SHELL",
+ "bash -c 'exec 3<>/dev/tcp/localhost/8080 && \
+ printf \"GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n\" >&3 && \
+ head -n1 <&3 | grep -q \" 200 \"'" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 20s
ports:
- "8080:8080"
depends_on:
@@ -40,3 +49,16 @@ services:
REACT_APP_KEYCLOAK_URL: http://localhost:8080
REACT_APP_KEYCLOAK_REALM: reports-realm
REACT_APP_KEYCLOAK_CLIENT_ID: reports-frontend
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ ports:
+ - "8000:8000"
+ extra_hosts:
+ - "localhost:host-gateway"
+ environment:
+ ISSUER: http://localhost:8080/realms/reports-realm
+ depends_on:
+ keycloak:
+ condition: service_healthy
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c5aaaf0e3..95ffc8e1a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,21 +4,28 @@ import Keycloak, { KeycloakConfig } from 'keycloak-js';
import ReportPage from './components/ReportPage';
const keycloakConfig: KeycloakConfig = {
- url: process.env.REACT_APP_KEYCLOAK_URL,
- realm: process.env.REACT_APP_KEYCLOAK_REALM||"",
- clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID||""
+ url: process.env.REACT_APP_KEYCLOAK_URL,
+ realm: process.env.REACT_APP_KEYCLOAK_REALM||"",
+ clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID||""
};
const keycloak = new Keycloak(keycloakConfig);
const App: React.FC = () => {
- return (
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+ );
};
export default App;
\ No newline at end of file