feat: add item notes and duplicate detection across platforms

- Add per-item notes editable in the detail and edit screens, synced
  between desktop and Android
- Guard against duplicates by external ID and by type/year/title,
  mirroring the desktop findDuplicate logic in MediaDao
- Wrap multi-step database operations in Room transactions
- Disable Android auto backup and remove the destructive Room
  migration fallback so schema bumps fail loudly instead of wiping data
- Bound crypto envelope KDF parameters when reading untrusted headers
- Handle malformed server responses in SyncClient instead of crashing
- Extend settings, database, and image cache on the desktop side
This commit is contained in:
Tronax 2026-08-16 12:04:28 +02:00
parent 85f5c6dd4e
commit 2b35139364
30 changed files with 837 additions and 124 deletions

View file

@ -28,6 +28,7 @@ type server struct {
oauth oauth2.Config
client *http.Client
store *store // nil when DATABASE_URL is unset (sync disabled)
tmdbLim *rateLimiter
}
func main() {
@ -54,6 +55,8 @@ func main() {
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
client: &http.Client{Timeout: 15 * time.Second},
// Generous per-user cap on proxied TMDB calls (shared API key).
tmdbLim: newRateLimiter(120, time.Minute),
}
if cfg.DatabaseURL != "" {
@ -184,7 +187,9 @@ func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) {
oauthToken, err := s.oauth.Exchange(ctx, r.URL.Query().Get("code"),
oauth2.VerifierOption(verifierCookie.Value))
if err != nil {
http.Error(w, "Token-Austausch fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
// Log the details server-side; don't leak upstream internals to clients.
log.Printf("oauth exchange failed: %v", err)
http.Error(w, "Token-Austausch fehlgeschlagen", http.StatusBadGateway)
return
}
@ -195,7 +200,8 @@ func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) {
}
idToken, err := s.verifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "id_token-Prüfung fehlgeschlagen: "+err.Error(), http.StatusUnauthorized)
log.Printf("id_token verification failed: %v", err)
http.Error(w, "id_token-Prüfung fehlgeschlagen", http.StatusUnauthorized)
return
}
@ -293,10 +299,15 @@ func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) {
}
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
if _, err := verifyToken(s.cfg.SessionKey, bearer); err != nil {
claims, err := verifyToken(s.cfg.SessionKey, bearer)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !s.tmdbLim.allow(claims.Subject) {
http.Error(w, "rate limit exceeded, try again later", http.StatusTooManyRequests)
return
}
// Build upstream URL: /3/<path> -> <TMDBBase>/<path>, keep the query, force api_key.
upstreamPath := strings.TrimPrefix(r.URL.Path, "/3")
@ -319,7 +330,8 @@ func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) {
resp, err := s.client.Do(req)
if err != nil {
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
log.Printf("tmdb upstream error for %s: %v", r.URL.Path, err)
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
defer resp.Body.Close()

48
server/ratelimit.go Normal file
View file

@ -0,0 +1,48 @@
package main
import (
"sync"
"time"
)
// rateLimiter is a minimal in-memory fixed-window limiter keyed by string
// (bearer-token subject for the TMDB proxy). It blunts runaway clients abusing
// the shared API key; it is not a full DoS defense.
type rateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
counts map[string]rateEntry
}
type rateEntry struct {
windowStart time.Time
count int
}
func newRateLimiter(limit int, per time.Duration) *rateLimiter {
return &rateLimiter{limit: limit, window: per, counts: map[string]rateEntry{}}
}
func (r *rateLimiter) allow(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
e, ok := r.counts[key]
if !ok || now.Sub(e.windowStart) >= r.window {
// Occasionally garbage-collect stale keys so the map stays bounded.
if len(r.counts) > 10_000 {
for k, v := range r.counts {
if now.Sub(v.windowStart) >= r.window {
delete(r.counts, k)
}
}
}
r.counts[key] = rateEntry{windowStart: now, count: 1}
return true
}
e.count++
r.counts[key] = e
return e.count <= r.limit
}