// 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) }