- 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é)
405 lines
9.9 KiB
Go
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)
|
|
}
|