diff --git a/Dockerfile b/Dockerfile index 1aea6b6f..841dae16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ RUN yarn --ignore-scripts \ && yarn build # ── Stage 2: Build the Go file server ──────────────────────────────────────── -FROM golang:1.22-alpine AS go-builder +FROM golang:1.25-alpine AS go-builder WORKDIR /build COPY httpserver/ . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o httpserver . @@ -39,8 +39,8 @@ COPY --from=go-builder /build/httpserver /app/httpserver RUN mkdir -p /app/uploads && \ chmod 755 /app/uploads -# Expose both Caddy (80) and httpServer (8080) ports -EXPOSE 80 8080 +# Expose both Caddy (80), httpServer (8080), and KOReader sync server (7200) ports +EXPOSE 80 8080 7200 # Create startup script to run both services RUN echo '#!/bin/sh' > /start.sh && \ @@ -50,9 +50,13 @@ RUN echo '#!/bin/sh' > /start.sh && \ chmod +x /start.sh # Set default environment variables (can be overridden at runtime) +ENV ENABLE_HTTP_SERVER=false ENV SERVER_USERNAME=admin ENV SERVER_PASSWORD=securePass123 ENV SERVER_PASSWORD_FILE=my_secret +ENV ENABLE_KOREADER_SERVER=false +ENV KOREADER_PORT=7200 +ENV KOREADER_ENABLE_REGISTRATION=true # Define volume for uploads directory VOLUME ["/app/uploads"] diff --git a/httpserver/go.mod b/httpserver/go.mod index f13e1156..6001998e 100644 --- a/httpserver/go.mod +++ b/httpserver/go.mod @@ -1,3 +1,16 @@ module httpserver -go 1.22 +go 1.25.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.1 // indirect +) diff --git a/httpserver/go.sum b/httpserver/go.sum new file mode 100644 index 00000000..605e1ebe --- /dev/null +++ b/httpserver/go.sum @@ -0,0 +1,21 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= diff --git a/httpserver/koreader.go b/httpserver/koreader.go new file mode 100644 index 00000000..ffed0354 --- /dev/null +++ b/httpserver/koreader.go @@ -0,0 +1,365 @@ +package main + +// KOReader Sync Server – compatible with koreader/koreader-sync-server +// +// Endpoints (all prefixed with no extra path; KOReader hits them directly): +// POST /users/create – register +// GET /users/auth – check credentials +// PUT /syncs/progress – update reading progress +// GET /syncs/progress/{doc} – get reading progress +// GET /healthcheck – liveness probe +// +// Authentication: HTTP headers x-auth-user / x-auth-key +// The client MD5-hashes the password before sending, so we store it as-is. +// +// Persistence: SQLite at /app/uploads/config/koreader.db +// (the directory is inside the Docker volume /app/uploads) + +import ( + "database/sql" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// ── KOReader server state ───────────────────────────────────────────────────── + +var ( + koreaderEnabled bool + koreaderPort string + koreaderDB *sql.DB + koreaderRegistrationEnabled bool +) + +func initKoreader() { + koreaderEnabled = os.Getenv("ENABLE_KOREADER_SERVER") == "true" + if !koreaderEnabled { + return + } + + koreaderPort = getEnv("KOREADER_PORT", "7200") + koreaderRegistrationEnabled = getEnv("KOREADER_ENABLE_REGISTRATION", "true") != "false" + + // Ensure the config directory exists inside the uploads volume. + dbDir := filepath.Join(uploadDir, "config") + if err := os.MkdirAll(dbDir, 0o755); err != nil { + log.Fatalf("[koreader] Cannot create config directory: %v", err) + } + + dbPath := filepath.Join(dbDir, "koreader.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + log.Fatalf("[koreader] Cannot open SQLite database: %v", err) + } + + // Single-writer; keep a small pool. + db.SetMaxOpenConns(1) + + if err := koreaderMigrate(db); err != nil { + log.Fatalf("[koreader] Migration failed: %v", err) + } + koreaderDB = db + log.Printf("[koreader] KOReader Sync Server enabled on port %s", koreaderPort) + log.Printf("[koreader] Database: %s", dbPath) +} + +func koreaderMigrate(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS progress ( + username TEXT NOT NULL, + document TEXT NOT NULL, + percentage REAL NOT NULL DEFAULT 0, + progress TEXT NOT NULL DEFAULT '', + device TEXT NOT NULL DEFAULT '', + device_id TEXT NOT NULL DEFAULT '', + timestamp INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (username, document) + ); + `) + return err +} + +// ── KOReader HTTP mux ───────────────────────────────────────────────────────── + +func startKoreaderServer() { + mux := http.NewServeMux() + mux.HandleFunc("/users/create", koreaderHandleCreateUser) + mux.HandleFunc("/users/auth", koreaderHandleAuth) + mux.HandleFunc("/syncs/progress", koreaderHandleProgress) // PUT + mux.HandleFunc("/syncs/progress/", koreaderHandleGetProgress) // GET /syncs/progress/{doc} + mux.HandleFunc("/healthcheck", koreaderHandleHealthcheck) + + srv := &http.Server{ + Addr: ":" + koreaderPort, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { koreaderRouter(mux, w, r) }), + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("[koreader] Listening on :%s", koreaderPort) + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("[koreader] Server error: %v", err) + } +} + +// koreaderRouter applies CORS and routes to the inner mux. +func koreaderRouter(mux *http.ServeMux, w http.ResponseWriter, r *http.Request) { + // CORS + origin := r.Header.Get("Origin") + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, x-auth-user, x-auth-key") + w.Header().Set("Vary", "Origin") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + mux.ServeHTTP(w, r) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// koreaderWriteJSON writes a JSON response with the given status and body. +func koreaderWriteJSON(w http.ResponseWriter, status int, v any) { + writeJSON(w, status, v) +} + +// koreaderError writes the canonical KOReader error JSON. +func koreaderError(w http.ResponseWriter, status, code int, message string) { + koreaderWriteJSON(w, status, map[string]any{ + "code": code, + "message": message, + }) +} + +// koreaderAuthenticateRequest validates x-auth-user / x-auth-key headers +// and returns the username on success, or "" on failure. +func koreaderAuthenticateRequest(r *http.Request) string { + username := r.Header.Get("x-auth-user") + password := r.Header.Get("x-auth-key") + if username == "" || password == "" { + return "" + } + // username must not contain colon (mirrors original key-field validation) + if strings.Contains(username, ":") { + return "" + } + var stored string + err := koreaderDB.QueryRow( + `SELECT password FROM users WHERE username = ?`, username, + ).Scan(&stored) + if err != nil || stored != password { + return "" + } + return username +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +// POST /users/create +// Body (JSON): { "username": "...", "password": "..." } +// Success 201: { "username": "..." } +// Error 402: username already registered +// Error 403: registration disabled / invalid fields +func koreaderHandleCreateUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writePlain(w, http.StatusMethodNotAllowed, "Method Not Allowed") + return + } + + if !koreaderRegistrationEnabled { + koreaderError(w, 402, 2005, "User registration is disabled.") + return + } + + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(r, &body); err != nil || body.Username == "" || body.Password == "" || + strings.Contains(body.Username, ":") { + koreaderError(w, 403, 2003, "Invalid request") + return + } + + _, err := koreaderDB.Exec( + `INSERT INTO users (username, password) VALUES (?, ?)`, + body.Username, body.Password, + ) + if err != nil { + // SQLite UNIQUE constraint violation + if strings.Contains(err.Error(), "UNIQUE") { + koreaderError(w, 402, 2002, "Username is already registered.") + return + } + log.Printf("[koreader] create_user error: %v", err) + koreaderError(w, 502, 2000, "Unknown server error.") + return + } + + koreaderWriteJSON(w, http.StatusCreated, map[string]any{ + "username": body.Username, + }) +} + +// GET /users/auth +// Headers: x-auth-user, x-auth-key +// Success 200: { "authorized": "OK" } +// Error 401: Unauthorized +func koreaderHandleAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writePlain(w, http.StatusMethodNotAllowed, "Method Not Allowed") + return + } + if koreaderAuthenticateRequest(r) == "" { + koreaderError(w, 401, 2001, "Unauthorized") + return + } + koreaderWriteJSON(w, http.StatusOK, map[string]any{ + "authorized": "OK", + }) +} + +// PUT /syncs/progress +// Headers: x-auth-user, x-auth-key +// Body (JSON): { "document": "...", "progress": "...", "percentage": 0.x, "device": "...", "device_id": "..." } +// Success 200: { "document": "...", "timestamp": } +func koreaderHandleProgress(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + writePlain(w, http.StatusMethodNotAllowed, "Method Not Allowed") + return + } + + username := koreaderAuthenticateRequest(r) + if username == "" { + koreaderError(w, 401, 2001, "Unauthorized") + return + } + + var body struct { + Document string `json:"document"` + Progress string `json:"progress"` + Percentage float64 `json:"percentage"` + Device string `json:"device"` + DeviceID string `json:"device_id"` + } + if err := decodeJSON(r, &body); err != nil { + koreaderError(w, 403, 2003, "Invalid request") + return + } + if body.Document == "" || strings.Contains(body.Document, ":") { + koreaderError(w, 403, 2004, "Field 'document' not provided.") + return + } + if body.Progress == "" || body.Device == "" { + koreaderError(w, 403, 2003, "Invalid request") + return + } + + timestamp := time.Now().Unix() + + _, err := koreaderDB.Exec(` + INSERT INTO progress (username, document, percentage, progress, device, device_id, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(username, document) DO UPDATE SET + percentage = excluded.percentage, + progress = excluded.progress, + device = excluded.device, + device_id = excluded.device_id, + timestamp = excluded.timestamp + `, username, body.Document, body.Percentage, body.Progress, body.Device, body.DeviceID, timestamp) + if err != nil { + log.Printf("[koreader] update_progress error: %v", err) + koreaderError(w, 502, 2000, "Unknown server error.") + return + } + + koreaderWriteJSON(w, http.StatusOK, map[string]any{ + "document": body.Document, + "timestamp": timestamp, + }) +} + +// GET /syncs/progress/{document} +// Headers: x-auth-user, x-auth-key +// Success 200: { "document": "...", "progress": "...", "percentage": 0.x, "device": "...", "device_id": "...", "timestamp": } +// +// or: {} when no progress stored yet +func koreaderHandleGetProgress(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writePlain(w, http.StatusMethodNotAllowed, "Method Not Allowed") + return + } + + username := koreaderAuthenticateRequest(r) + if username == "" { + koreaderError(w, 401, 2001, "Unauthorized") + return + } + + // Extract document from URL: /syncs/progress/{document} + doc := strings.TrimPrefix(r.URL.Path, "/syncs/progress/") + doc = strings.TrimSpace(doc) + if doc == "" || strings.Contains(doc, ":") { + koreaderError(w, 403, 2004, "Field 'document' not provided.") + return + } + + var ( + percentage float64 + progress string + device string + deviceID string + timestamp int64 + ) + err := koreaderDB.QueryRow(` + SELECT percentage, progress, device, device_id, timestamp + FROM progress + WHERE username = ? AND document = ? + `, username, doc).Scan(&percentage, &progress, &device, &deviceID, ×tamp) + + if err == sql.ErrNoRows { + // Return empty object – mirrors the original server behaviour + koreaderWriteJSON(w, http.StatusOK, map[string]any{}) + return + } + if err != nil { + log.Printf("[koreader] get_progress error: %v", err) + koreaderError(w, 502, 2000, "Unknown server error.") + return + } + + result := map[string]any{ + "document": doc, + "percentage": percentage, + "progress": progress, + "device": device, + "timestamp": timestamp, + } + if deviceID != "" { + result["device_id"] = deviceID + } + koreaderWriteJSON(w, http.StatusOK, result) +} + +// GET /healthcheck +// Returns: { "state": "OK" } +func koreaderHandleHealthcheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writePlain(w, http.StatusMethodNotAllowed, "Method Not Allowed") + return + } + koreaderWriteJSON(w, http.StatusOK, map[string]any{"state": "OK"}) +} diff --git a/httpserver/main.go b/httpserver/main.go index 6ebbb982..868d9e84 100644 --- a/httpserver/main.go +++ b/httpserver/main.go @@ -17,6 +17,11 @@ import ( "time" ) +// decodeJSON decodes a JSON request body into v. +func decodeJSON(r *http.Request, v any) error { + return json.NewDecoder(r.Body).Decode(v) +} + // ── Configuration ──────────────────────────────────────────────────────────── var ( @@ -489,8 +494,13 @@ func handler(w http.ResponseWriter, r *http.Request) { } func main() { - if !serverEnabled { - log.Println("HTTP Server is disabled. Set ENABLE_HTTP_SERVER=true to enable it.") + // Initialise KOReader sync server (reads env, opens DB if enabled). + initKoreader() + + if !serverEnabled && !koreaderEnabled { + log.Println("All servers are disabled.") + log.Println(" Set ENABLE_HTTP_SERVER=true to enable the file server.") + log.Println(" Set ENABLE_KOREADER_SERVER=true to enable the KOReader sync server.") os.Exit(0) } @@ -498,17 +508,28 @@ func main() { log.Fatalf("Cannot create uploads directory: %v", err) } - addr := ":" + port - log.Printf("Secure File Server running at http://localhost%s", addr) - log.Printf("Username: %s", credentials.username) - log.Println("Password: [HIDDEN FOR SECURITY]") - - srv := &http.Server{ - Addr: addr, - Handler: http.HandlerFunc(handler), - ReadTimeout: 5 * time.Minute, // allow large uploads - WriteTimeout: 5 * time.Minute, - IdleTimeout: 60 * time.Second, + // Start KOReader sync server in background if enabled. + if koreaderEnabled { + go startKoreaderServer() + } + + // Start the main file server if enabled. + if serverEnabled { + addr := ":" + port + log.Printf("Secure File Server running at http://localhost%s", addr) + log.Printf("Username: %s", credentials.username) + log.Println("Password: [HIDDEN FOR SECURITY]") + + srv := &http.Server{ + Addr: addr, + Handler: http.HandlerFunc(handler), + ReadTimeout: 5 * time.Minute, // allow large uploads + WriteTimeout: 5 * time.Minute, + IdleTimeout: 60 * time.Second, + } + log.Fatal(srv.ListenAndServe()) + } else { + // Block forever while the KOReader goroutine runs. + select {} } - log.Fatal(srv.ListenAndServe()) } diff --git a/src/utils/file/koReaderSync.ts b/src/utils/file/koReaderSync.ts index 50906615..180da7fb 100644 --- a/src/utils/file/koReaderSync.ts +++ b/src/utils/file/koReaderSync.ts @@ -265,6 +265,9 @@ const buildLocalUploadPayload = async ( koreaderBook.document = partialMD5; ConfigService.setObjectConfig(book.key, koreaderBook, "koreaderBooks"); } + if (!cachedProgress) { + return null; + } return { document: koreaderBook.document, percentage,