homepage/backend/main.go
Nathan Leclercq f795cc48b5 Homepage dashboard : horloge animée, todo, notes, RSS, pomodoro, recherche
- Frontend Vue 3 (Composition API) + Vite + Vue Router
- Backend Go (stdlib) : API REST todo/notes + proxy RSS + auth token
- Docker Compose : SPA nginx + backend + Miniflux + Postgres
- Widgets : horloge canvas météo, todo 3 colonnes, notes persistées,
  agrégateur RSS multi-feeds, pomodoro, recherche DuckDuckGo (Ctrl+K)
- Auth : dashboard public, todo/notes protégés par token
- Widgets expandables (mode agrandi centré)
2026-04-09 12:49:55 +02:00

405 lines
9.9 KiB
Go

// homepage-backend — tiny REST API for the homepage todo board.
//
// Storage: a single JSON file at $DATA_PATH (default /data/todos.json).
// No external deps — stdlib only. Mounted as a docker volume for persistence.
//
// Routes:
//
// GET /api/health
// GET /api/tasks?from=YYYY-MM-DD&to=YYYY-MM-DD
// POST /api/tasks { title, details?, due_date? }
// PATCH /api/tasks/{id} partial update
// DELETE /api/tasks/{id}
//
// On every list request, undone tasks whose due_date is in the past
// are auto-bumped to today (rollover).
package main
import (
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Details string `json:"details"`
DueDate string `json:"due_date"` // YYYY-MM-DD
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type Note struct {
ID int64 `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at"`
}
type Store struct {
mu sync.Mutex
Tasks []Task `json:"tasks"`
Next int64 `json:"next_id"`
Notes []Note `json:"notes"`
NoteNext int64 `json:"note_next_id"`
path string
}
func (s *Store) load() error {
s.mu.Lock()
defer s.mu.Unlock()
b, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
s.Next = 1
return nil
}
return err
}
if err := json.Unmarshal(b, s); err != nil {
return err
}
if s.Next == 0 {
s.Next = 1
}
if s.NoteNext == 0 {
s.NoteNext = 1
}
return nil
}
// save must be called with the mutex held.
func (s *Store) save() error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// rollover must be called with the mutex held.
func (s *Store) rollover() {
today := time.Now().Format("2006-01-02")
now := time.Now().UTC().Format(time.RFC3339)
changed := false
for i := range s.Tasks {
if !s.Tasks[i].Done && s.Tasks[i].DueDate < today {
s.Tasks[i].DueDate = today
s.Tasks[i].UpdatedAt = now
changed = true
}
}
if changed {
_ = s.save()
}
}
var store *Store
var apiToken string
func main() {
path := getenv("DATA_PATH", "/data/todos.json")
apiToken = getenv("API_TOKEN", "")
if apiToken == "" {
log.Println("WARNING: API_TOKEN not set — notes/tasks endpoints are unprotected")
}
store = &Store{path: path}
if err := store.load(); err != nil {
log.Fatalf("failed to load store: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("/api/auth", handleAuth)
mux.HandleFunc("/api/tasks", requireAuth(handleTasks))
mux.HandleFunc("/api/tasks/", requireAuth(handleTask))
mux.HandleFunc("/api/notes", requireAuth(handleNotes))
mux.HandleFunc("/api/notes/", requireAuth(handleNote))
mux.HandleFunc("/api/rss", handleRSS)
addr := ":" + getenv("PORT", "8080")
log.Println("homepage-backend listening on", addr, "store:", path)
if err := http.ListenAndServe(addr, cors(mux)); err != nil {
log.Fatal(err)
}
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func checkAuth(r *http.Request) bool {
if apiToken == "" {
return true
}
h := r.Header.Get("Authorization")
return h == "Bearer "+apiToken
}
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func handleAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var in struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if apiToken != "" && in.Token != apiToken {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
func cors(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
h.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func handleTasks(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
store.mu.Lock()
store.rollover()
out := make([]Task, 0, len(store.Tasks))
for _, t := range store.Tasks {
if (from == "" || t.DueDate >= from) && (to == "" || t.DueDate <= to) {
out = append(out, t)
}
}
store.mu.Unlock()
writeJSON(w, out)
case http.MethodPost:
var in Task
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if strings.TrimSpace(in.Title) == "" {
http.Error(w, "title required", http.StatusBadRequest)
return
}
if in.DueDate == "" {
in.DueDate = time.Now().Format("2006-01-02")
}
now := time.Now().UTC().Format(time.RFC3339)
store.mu.Lock()
in.ID = store.Next
store.Next++
in.CreatedAt = now
in.UpdatedAt = now
in.Done = false
store.Tasks = append(store.Tasks, in)
_ = store.save()
store.mu.Unlock()
writeJSON(w, in)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleRSS proxies a remote RSS/Atom feed (so the SPA can read it without
// hitting CORS). Basic guards: only http/https, response capped at 5MB,
// 10s timeout.
func handleRSS(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("url")
if target == "" || !(strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://")) {
http.Error(w, "missing or invalid url", http.StatusBadRequest)
return
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", "homepage-rss-proxy/1.0")
resp, err := client.Do(req)
if err != nil {
http.Error(w, "upstream fetch failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
http.Error(w, "upstream "+resp.Status, http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = io.Copy(w, io.LimitReader(resp.Body, 5*1024*1024))
}
func handleTask(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodPatch:
var in struct {
Title *string `json:"title"`
Details *string `json:"details"`
DueDate *string `json:"due_date"`
Done *bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
store.mu.Lock()
defer store.mu.Unlock()
for i := range store.Tasks {
if store.Tasks[i].ID != id {
continue
}
if in.Title != nil {
store.Tasks[i].Title = *in.Title
}
if in.Details != nil {
store.Tasks[i].Details = *in.Details
}
if in.DueDate != nil {
store.Tasks[i].DueDate = *in.DueDate
}
if in.Done != nil {
store.Tasks[i].Done = *in.Done
}
store.Tasks[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339)
_ = store.save()
writeJSON(w, store.Tasks[i])
return
}
http.NotFound(w, r)
case http.MethodDelete:
store.mu.Lock()
defer store.mu.Unlock()
for i := range store.Tasks {
if store.Tasks[i].ID == id {
store.Tasks = append(store.Tasks[:i], store.Tasks[i+1:]...)
_ = store.save()
w.WriteHeader(http.StatusNoContent)
return
}
}
http.NotFound(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// --- Notes ---
func handleNotes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
store.mu.Lock()
out := make([]Note, len(store.Notes))
copy(out, store.Notes)
store.mu.Unlock()
writeJSON(w, out)
case http.MethodPost:
var in Note
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if strings.TrimSpace(in.Text) == "" {
http.Error(w, "text required", http.StatusBadRequest)
return
}
store.mu.Lock()
in.ID = store.NoteNext
store.NoteNext++
in.CreatedAt = time.Now().UTC().Format(time.RFC3339)
store.Notes = append([]Note{in}, store.Notes...)
if len(store.Notes) > 50 {
store.Notes = store.Notes[:50]
}
_ = store.save()
store.mu.Unlock()
writeJSON(w, in)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleNote(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/notes/")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
store.mu.Lock()
defer store.mu.Unlock()
for i := range store.Notes {
if store.Notes[i].ID == id {
store.Notes = append(store.Notes[:i], store.Notes[i+1:]...)
_ = store.save()
w.WriteHeader(http.StatusNoContent)
return
}
}
http.NotFound(w, r)
}