Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@ backend/database/
.env
**/*.env.unenc
# age encryption key
backend/deploy/*-key.txt
backend/deploy/*-key.txt
# real spaces config (keep only the example in the repo)
backend/deploy/config/spaces.yaml
backend/config/spaces.yaml
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,24 @@ ora, aprire [http://localhost:6052](http://localhost:6052) sul proprio browser e

### backend

il backend è un semplice web server in Go, espone:

- `GET /status`: risponde `true` o `false`
- `POST /toggle`: cambia lo stato della sede e ritorna il nuovo stato
- `GET /stats`: ritorna le statistiche orario con probabilità di trovare la sede aperta o chiusa in base allo storico
- `GET /ui`: attiva solo se `DEBUG=true`
il backend è un semplice web server in Go, multi-tenant: una sola
istanza serve N sedi tramite prefisso di path `/s/{slug}/...`. Le rotte
"bare" restano come alias della sede di default (`DEFAULT_SPACE_SLUG`,
`pescara`) per compatibilità con i client già deployati (pulsante
ESP32, MCP server).

Endpoint per ciascuna sede:

- `GET /s/{slug}/status` (alias: `GET /status`): risponde `true` o `false`
- `POST /s/{slug}/toggle` (alias: `POST /toggle`): cambia lo stato. Richiede `X-API-KEY` della sede.
- `GET /s/{slug}/stats` (alias: `GET /stats`): statistiche orarie
- `GET /s/{slug}/spaceapi.json` (alias: `GET /spaceapi.json`): metadati SpaceAPI v15
- `GET /s/{slug}/ui` (alias: `GET /ui`): heatmap, attiva solo se `DEBUG=true`

Le sedi sono dichiarate in `config/spaces.yaml` (vedi
`backend/deploy/spaces.example.yaml`): slug, nome, coordinate, API key
(supporta `$VAR`), chat/thread Telegram, metadati SpaceAPI. Il file è
caricato al boot e fa upsert sulle righe del DB per slug.

per lanciarlo in locale:

Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# build backend
FROM golang:1.23.4 AS builder
FROM golang:1.24 AS builder

WORKDIR /app

Expand Down
7 changes: 7 additions & 0 deletions backend/cmd/server/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ func init() {
rootCmd.PersistentFlags().Int64Var(&cfg.TelegramChatId, "telegram-chat-id", 0, "Telegram chat ID")
rootCmd.PersistentFlags().IntVar(&cfg.TelegramChatThreadId, "telegram-chat-thread-id", 0, "Telegram chat thread ID")

rootCmd.PersistentFlags().StringVar(&cfg.SpacesConfigPath, "spaces-config-path", "", "Path to the spaces.yaml config file")
rootCmd.PersistentFlags().StringVar(&cfg.DefaultSpaceSlug, "default-space-slug", "", "Slug of the space that legacy bare routes resolve to")

// Bind flags to viper
viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
viper.BindPFlag("api_key", rootCmd.PersistentFlags().Lookup("api-key"))
Expand All @@ -46,6 +49,8 @@ func init() {
viper.BindPFlag("telegram_token", rootCmd.PersistentFlags().Lookup("telegram-token"))
viper.BindPFlag("telegram_chat_id", rootCmd.PersistentFlags().Lookup("telegram-chat-id"))
viper.BindPFlag("telegram_chat_thread_id", rootCmd.PersistentFlags().Lookup("telegram-chat-thread-id"))
viper.BindPFlag("spaces_config_path", rootCmd.PersistentFlags().Lookup("spaces-config-path"))
viper.BindPFlag("default_space_slug", rootCmd.PersistentFlags().Lookup("default-space-slug"))
}

func initConfig() {
Expand All @@ -61,6 +66,8 @@ func initConfig() {
cfg.TelegramToken = viper.GetString("telegram_token")
cfg.TelegramChatId = viper.GetInt64("telegram_chat_id")
cfg.TelegramChatThreadId = viper.GetInt("telegram_chat_thread_id")
cfg.SpacesConfigPath = viper.GetString("spaces_config_path")
cfg.DefaultSpaceSlug = viper.GetString("default_space_slug")
}

func Execute() {
Expand Down
1 change: 1 addition & 0 deletions backend/deploy/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ services:
pull_policy: always
volumes:
- ./database:/app/database
- ./config:/app/config:ro
networks:
- "internal-apps"
env_file: backend.env
Expand Down
61 changes: 61 additions & 0 deletions backend/deploy/spaces.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# spaces.yaml — one entry per physical space served by this instance.
#
# Fields with $VAR references are resolved from the environment at boot;
# a missing env var fails startup so secrets can't silently be empty.
# Entries are upserted into the DB keyed on slug: change a field and
# restart to roll it out. Deleting an entry leaves its DB row alone
# (and its historical sede_statuses) — safer than implicit cascades.
#
# The bare legacy routes (/status, /toggle, /stats, /spaceapi.json, /ui)
# resolve to DEFAULT_SPACE_SLUG. Point new clients at /s/<slug>/... .

spaces:
- slug: pescara
name: Metro Olografix Pescara
address: Viale Marconi 278/1, 65126 Pescara, Italy
lat: 42.454657
lon: 14.224055
timezone: Europe/Rome
logo_url: https://olografix.org/images/metro-dark.png
url: https://olografix.org
contact:
email: info@olografix.org
message: We meet every Monday evening from 9:00 PM
api_key: $PESCARA_API_KEY
telegram:
chat_id: -1001234567890
thread_id: 1
projects:
- https://github.com/Metro-Olografix
links:
- name: MOCA - Metro Olografix Camp
description: Il più antico campeggio hacker in Italia
url: https://moca.camp
- name: Wikipedia
description: Metro Olografix Wikipedia page
url: https://it.wikipedia.org/wiki/Metro_Olografix

- slug: aquila
name: Metro Olografix L'Aquila
address: TBD, L'Aquila, Italy
lat: 44.494887
lon: 11.342616
timezone: Europe/Rome
logo_url: https://olografix.org/images/metro-dark.png
url: https://olografix.org
contact:
email: aquila@olografix.org
message: Opening soon
api_key: $AQUILA_API_KEY
telegram:
chat_id: -1009876543210
thread_id: 1
projects:
- https://github.com/Metro-Olografix
links:
- name: MOCA - Metro Olografix Camp
description: Il più antico campeggio hacker in Italia
url: https://moca.camp
- name: Wikipedia
description: Metro Olografix Wikipedia page
url: https://it.wikipedia.org/wiki/Metro_Olografix
20 changes: 20 additions & 0 deletions backend/docker-compose.dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
services:
sede:
build: .
restart: "no"
ports:
- "8080:8080"
volumes:
- ./database:/app/database
- ./ui:/app/ui:ro
- ./deploy/spaces.example.yaml:/app/config/spaces.yaml:ro
environment:
PORT: "8080"
DEBUG: "true"
DATABASE_PATH: database/sede.db
SPACES_CONFIG_PATH: config/spaces.yaml
DEFAULT_SPACE_SLUG: pescara
# Keys referenced by $VAR in spaces.example.yaml
PESCARA_API_KEY: pescara-key-1234567890
AQUILA_API_KEY: aquila-key-1234567890
API_KEY: legacy-test-key-1234
4 changes: 2 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ require (
github.com/spf13/viper v1.19.0
github.com/ulule/limiter/v3 v3.11.2
golang.org/x/crypto v0.45.0
golang.org/x/net v0.47.0
golang.org/x/time v0.9.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
Expand Down Expand Up @@ -57,9 +57,9 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
111 changes: 94 additions & 17 deletions backend/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package app

import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"time"

"github.com/go-playground/validator/v10"
Expand All @@ -18,13 +21,14 @@ import (
)

type App struct {
repo *database.Repository
config config.Config
validate *validator.Validate
limiter *rate.Limiter
apiKeyHash []byte
rateLimiter *limiter.Limiter
telegram *notification.Telegram
repo *database.Repository
config config.Config
validate *validator.Validate
limiter *rate.Limiter
rateLimiter *limiter.Limiter
telegram *notification.Dispatcher
spaces map[string]*database.Space
defaultSpace *database.Space
}

const (
Expand All @@ -39,10 +43,7 @@ func NewApp(cfg config.Config) (*App, error) {
config: cfg,
validate: validator.New(),
limiter: rate.NewLimiter(rate.Every(rateLimitDuration/rateLimitRequests), rateLimitRequests),
}

if err := app.initSecurity(); err != nil {
return nil, err
spaces: make(map[string]*database.Space),
}

repo, err := database.New(cfg)
Expand All @@ -56,23 +57,99 @@ func NewApp(cfg config.Config) (*App, error) {
Limit: rateLimitRequests,
})

telegram, err := notification.NewTelegram(cfg)
telegram, err := notification.NewDispatcher(cfg.TelegramToken)
if err != nil {
log.Printf("telegram notification not initialized: %s", err.Error())
}
app.telegram = telegram

if err := app.loadAndSeedSpaces(); err != nil {
return nil, fmt.Errorf("space bootstrap failed: %w", err)
}

return app, nil
}

func (a *App) initSecurity() error {
if a.config.HashAPIKey {
hash, err := bcrypt.GenerateFromPassword([]byte(a.config.APIKey), bcrypt.DefaultCost)
// loadAndSeedSpaces reads spaces.yaml (or synthesises a single space from the
// legacy env vars when the file is missing), upserts every entry into the DB
// with a bcrypt-hashed API key, builds the hot lookup map, and backfills any
// legacy status rows carrying space_id = 0 onto the default space.
func (a *App) loadAndSeedSpaces() error {
slug := a.config.DefaultSpaceSlug
if slug == "" {
slug = "pescara"
}

defs, err := config.LoadSpaces(a.config.SpacesConfigPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
if a.config.APIKey == "" {
return fmt.Errorf("no spaces config at %s and no legacy API_KEY to synthesise a default space", a.config.SpacesConfigPath)
}
legacy := config.LegacySpaceFromConfig(a.config)
if legacy.Slug == "" {
legacy.Slug = slug
}
defs = []config.SpaceDef{legacy}
log.Printf("spaces config not found at %s; synthesising single space %q from legacy env vars", a.config.SpacesConfigPath, legacy.Slug)
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

for _, d := range defs {
hash, err := bcrypt.GenerateFromPassword([]byte(d.APIKey), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash API key: %w", err)
return fmt.Errorf("hash api key for space %q: %w", d.Slug, err)
}
a.apiKeyHash = hash
projectsJSON, err := json.Marshal(d.Projects)
if err != nil {
return fmt.Errorf("encode projects for space %q: %w", d.Slug, err)
}
linksJSON, err := json.Marshal(d.Links)
if err != nil {
return fmt.Errorf("encode links for space %q: %w", d.Slug, err)
}

sp, err := a.repo.UpsertSpace(ctx, database.Space{
Slug: d.Slug,
Name: d.Name,
Address: d.Address,
Lat: d.Lat,
Lon: d.Lon,
Timezone: d.Timezone,
LogoURL: d.LogoURL,
URL: d.URL,
ContactEmail: d.ContactEmail,
Message: d.Message,
APIKeyHash: hash,
TelegramChatID: d.TelegramChatID,
TelegramThread: d.TelegramThread,
Projects: string(projectsJSON),
Links: string(linksJSON),
})
if err != nil {
return fmt.Errorf("upsert space %q: %w", d.Slug, err)
}
a.spaces[sp.Slug] = sp
}

ds, ok := a.spaces[slug]
if !ok {
return fmt.Errorf("default space slug %q not found in loaded spaces", slug)
}
a.defaultSpace = ds

n, err := a.repo.BackfillDefaultSpaceID(ctx, ds.ID)
if err != nil {
return fmt.Errorf("backfill legacy sede_statuses: %w", err)
}
if n > 0 {
log.Printf("backfilled %d legacy sede_statuses rows onto space %q (id=%d)", n, ds.Slug, ds.ID)
}

return nil
}

Expand Down
Loading
Loading