feat: add KOReader sync server with user registration and progress tracking functionalities

This commit is contained in:
troyeguo
2026-05-16 09:49:00 +08:00
parent 8e0fea9ca2
commit 01989e14ff
6 changed files with 445 additions and 18 deletions

View File

@@ -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
)

21
httpserver/go.sum Normal file
View File

@@ -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=

365
httpserver/koreader.go Normal file
View File

@@ -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": <unix> }
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": <unix> }
//
// 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, &timestamp)
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"})
}

View File

@@ -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())
}