mirror of
https://github.com/koodo-reader/koodo-reader.git
synced 2026-06-11 09:24:48 -04:00
feat: add KOReader sync server with user registration and progress tracking functionalities
This commit is contained in:
@@ -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
21
httpserver/go.sum
Normal 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
365
httpserver/koreader.go
Normal 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, ×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"})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user