diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..3a14218 --- /dev/null +++ b/.containerignore @@ -0,0 +1,11 @@ +.containerignore +.git +.gitignore +.gitattributes +bash-scripts +Containerfile +README.md +xml-template +cache +etc +library diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..77c0f5f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1 @@ +By interacting with this repository you agree that any emotional damage is your own damn fault. diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..5ef177e --- /dev/null +++ b/Containerfile @@ -0,0 +1,32 @@ +# Build stage +FROM golang:bookworm AS builder + +# Install UPX +RUN apt-get update && apt-get install -y wget xz-utils && rm -rf /var/lib/apt/lists/* + +RUN wget https://github.com/upx/upx/releases/download/v5.0.2/upx-5.0.2-amd64_linux.tar.xz +RUN tar -xf upx-5.0.2-amd64_linux.tar.xz && mv upx-5.0.2-amd64_linux/upx /usr/local/bin/upx && rm -r upx-5.0.2-amd64_linux upx-5.0.2-amd64_linux.tar.xz + +WORKDIR /app + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN mkdir -p /var/sockets +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w -extldflags '-static' -X main.GOMEMLIMIT=256MiB -X runtime.defaultGOGC=50" -trimpath -gcflags="-l=4" -asmflags=-trimpath -o bin/main app/gopherbook/main.go +RUN upx --best --ultra-brute bin/main +RUN chmod +x bin/main + +# Final stage with Chainguard static +FROM cgr.dev/chainguard/static:latest +WORKDIR /app + +# Copy the binary +COPY --from=builder /app/bin/main ./bin/main + +# Create directories that will be mounted and set ownership +EXPOSE 8080 +USER root:root +CMD ["./bin/main"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b35358f --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (c) 2025 Riomoo [alister at kamikishi dot net] + +This software is licensed under the Prism Information License (PIL). + +- You are free to use, modify, and distribute this software. +- Any derivative work must include this license. +- You must also provide a concise explanation of how to operate this software, + as well as backends and frontends (if any) that work with this software. +- You must credit the original creator of this software. +- You may choose to license this software under additional licenses, provided that the terms of the original PIL are still adhered to. + +This software is provided "as is", without any warranty of any kind, +express or implied, including but not limited to the warranties of merchantability, +fitness for a particular purpose, and non-infringement. + +By using this software, you agree to the terms of the PIL. +For more information visit: https://pil.jester-designs.com/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8adf6e4 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# Gopherbook – Self-Hosted Comic Library & CBZ Reader + +Gopherbook is a lightweight, single-binary, self-hosted web comic reader and library manager written in Go. +It is designed for people who want full control over their digital comic collection (CBZ files), including support for password-protected/encrypted archives, per-user libraries, tagging, automatic organization, and a clean modern reader. + +## License + +[![Custom badge](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fshare.jester-designs.com%2Fview%2Fpil.json)](LICENSE) + +## Features + +- Upload & read `.cbz` (ZIP-based) comics directly in the browser +- Full support for password-protected/encrypted CBZ files (AES-256 via yeka/zip) +- Automatically tries all previously successful passwords when opening a new encrypted comic +- Persists discovered passwords securely (AES-encrypted on disk, key derived from your login password) +- Extracts ComicInfo.xml metadata (title, series, number, writer, inker, tags, story arc, etc.) +- Automatic folder organization: `Library/Artist/StoryArc/Comic.cbz` +- Manual reorganization via UI if needed +- Powerful tagging system with custom colors and counts +- Filter comics by any combination of tags +- Responsive grid view with cached JPEG covers +- Full-screen web reader with: + - Page pre-loading + - Zoom / pan + - Fit-to-width/height/page + - Keyboard navigation (←→, A/D, +/-, Esc) +- Multi-user support: + - Each user has their own completely isolated library and password vault + - First registered user becomes admin + - Admin can disable new registrations + - Admin can delete any comic +- No external database – everything stored in simple JSON files +- Single static Go binary + file storage – easy to deploy + +## Installation / Running + +### Prerequisites +[![Go](https://img.shields.io/badge/go-%2300ADD8.svg?style=for-the-badge&logo=go&logoColor=white)](https://golang.org/dl/) +- Go 1.25.2+ (only needed to build) +- Or just download a pre-built binary from Releases (when available) + +### Quick start (from source) + +```bash +git clone https://codeberg.org/riomoo/gopherbook.git +cd gopherbook +go build -o gopherbook app/gopherbook/main.go +./gopherbook +``` + +Then open http://localhost:8080 in your browser. + +## If you want to use this with podman: + +```bash +git clone https://codeberg.org/riomoo/gopherbook.git +cd gopherbook +./bash-scripts/run.sh +``` + +Then open http://localhost:12010 in your browser. + +### First launch +1. On first run there are no users → registration is open +2. Create the first account → this user automatically becomes admin +3. Log in → start uploading CBZ files + +## Directory layout after first login + +``` +./library/username/ ← your comics (organized or Unorganized/) +./library/username/comics.json ← metadata index +./library/username/tags.json ← tag definitions & counts +./library/username/passwords.json ← encrypted password vault (AES) +./cache/covers/username/ ← generated cover thumbnails +./etc/users.json ← user accounts (bcrypt hashes) +./etc/admin.json ← admin settings (registration toggle) +``` + +## How encrypted/password-protected comics work + +- When you upload or scan an encrypted CBZ that has no known password yet, the server marks it as Encrypted = true. +- The first time you open it in the reader, a password prompt appears. +- If the password is correct, Gopherbook: + - Stores the password (encrypted with a key derived from your login password) + - Extracts ComicInfo.xml metadata + - Auto-organizes the file into Artist/StoryArc folders + - Updates tags and cover cache +- From then on the comic opens instantly, and that password is automatically tried on every future encrypted comic you upload (so whole collections that share one password "just work"). + +## Security notes + +- Passwords are stored encrypted on disk using AES-256-CFB with a key derived from your login password via SHA-256. +- Session cookie is HttpOnly, expires after 24 h. +- No external dependencies that phone home. +- Still: treat this as a personal/private server – do not expose it publicly without HTTPS/reverse-proxy auth. + +## Config for NGINX to use as a website: +``` +upstream gopherbook { + server 127.0.0.1:8080; + #server 127.0.0.1:12010; #For Podman instead + server [::1]:8080; + #server [::1]:12010; #For Podman instead +} +server { + listen 80; + listen [::1]:80; + server_name gopherbook.example.com; + location / { + proxy_pass http://gopherbook; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # 1. Allow very large uploads (e.g. 500 MB – adjust as needed) + client_max_body_size 500M; # 0 = unlimited, but never do that on a public server + + # 2. Give the upload enough time (important for slow connections) + client_body_timeout 5m; # time to read the entire request body (default 60s) + proxy_read_timeout 5m; # if you're proxying to your Go app + proxy_send_timeout 5m; + + # 3. Increase buffer sizes so Nginx doesn't spill everything to disk + client_body_buffer_size 512k; # default 8k/16k – too small for big uploads + proxy_buffers 8 512k; + proxy_buffer_size 256k; + + # 4. Recommended: put uploads in a temporary directory with plenty of space + client_body_temp_path /var/lib/nginx/body 1 2; # make sure this directory exists and is writable by nginx + } +} +``` + +## Contributing + +Pull requests are welcome! Especially: +- Better mobile reader experience +- Bulk tag editing +- Search box +- OPDS catalog endpoint +- More metadata sources (ComicVine, etc.) + +Please open an issue first for bigger changes. + +## Thanks / Credits + +- yeka/zip – password-protected ZIP support in pure Go +- The ComicRack ComicInfo.xml standard +- Everyone who hoards comics ❤️ + +Enjoy your library! +— Happy reading with Gopherbook + +
+ +## Software Used but not included + +![Arch](https://img.shields.io/badge/Arch%20Linux-1793D1?logo=arch-linux&logoColor=fff&style=for-the-badge) +![Gimp Gnu Image Manipulation Program](https://img.shields.io/badge/Gimp-657D8B?style=for-the-badge&logo=gimp&logoColor=FFFFFF) +![Vim](https://img.shields.io/badge/VIM-%2311AB00.svg?style=for-the-badge&logo=vim&logoColor=white) +![Git](https://img.shields.io/badge/git-%23F05033.svg?style=for-the-badge&logo=git&logoColor=white) +![Forgejo](https://img.shields.io/badge/forgejo-%23FB923C.svg?style=for-the-badge&logo=forgejo&logoColor=white) + +
+ +## Creator: +- [Codeberg Riomoo](https://codeberg.org/riomoo) +- [GitGud Riomoo](https://gitgud.io/riomoo) +- [Github Riomoo](https://github.com/riomoo) diff --git a/app/gopherbook/main.go b/app/gopherbook/main.go new file mode 100644 index 0000000..4c8ed0c --- /dev/null +++ b/app/gopherbook/main.go @@ -0,0 +1,3328 @@ +package main + +import ( + "archive/zip" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "crypto/rand" + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "regexp" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + yzip "github.com/yeka/zip" +) + +// ComicInfo represents the standard ComicInfo.xml metadata +type ComicInfo struct { + XMLName xml.Name `xml:"ComicInfo"` + Title string `xml:"Title"` + Series string `xml:"Series"` + Number string `xml:"Number"` + Writer string `xml:"Writer"` + Artist string `xml:"Artist"` + Inker string `xml:"Inker"` + Publisher string `xml:"Publisher"` + Genre string `xml:"Genre"` // Standard field + TagsXml string `xml:"Tags"` // User-requested field for flexibility + StoryArc string `xml:"StoryArc"` + Year string `xml:"Year"` + Month string `xml:"Month"` + Summary string `xml:"Summary"` + PageCount int `xml:"PageCount"` +} + +type User struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + IsAdmin bool `json:"is_admin"` // NEW +} + +type Comic struct { + ID string `json:"id"` + Filename string `json:"filename"` + Artist string `json:"artist"` + Title string `json:"title"` + Series string `json:"series"` + StoryArc string `json:"story_arc"` + Number string `json:"number"` + Publisher string `json:"publisher"` + Year string `json:"year"` + PageCount int `json:"page_count"` + CoverImage string `json:"cover_image"` + FilePath string `json:"file_path"` + FileType string `json:"file_type"` + Encrypted bool `json:"encrypted"` + HasPassword bool `json:"has_password"` + Password string `json:"-"` // Don't expose password in JSON + Tags []string `json:"tags"` + UploadedAt time.Time `json:"uploaded_at"` +} + +type Session struct { + Username string + ExpiresAt time.Time +} + +type Tag struct { + Name string `json:"name"` + Color string `json:"color"` + Count int `json:"count"` +} + +var ( + users = make(map[string]User) + sessions = make(map[string]Session) + comics = make(map[string]Comic) + tags = make(map[string]Tag) + comicPasswords = make(map[string]string) + comicsMutex sync.RWMutex + sessionsMutex sync.RWMutex + tagsMutex sync.RWMutex + passwordsMutex sync.RWMutex + currentEncryptionKey []byte + libraryPath = "./library" + cachePath = "./cache/covers" + etcPath = "./etc" + currentUser string + registrationEnabled = true +) + +func main() { + // Initialize directories + os.MkdirAll(filepath.Join(libraryPath, "Unorganized"), 0755) + os.MkdirAll(cachePath, 0755) + os.MkdirAll(etcPath, 0755) + + // Load users, comics, and tags + loadUsers() + // Setup routes + http.HandleFunc("/api/register", handleRegister) + http.HandleFunc("/api/login", handleLogin) + http.HandleFunc("/api/logout", handleLogout) + http.HandleFunc("/api/comics", authMiddleware(handleComics)) + http.HandleFunc("/api/upload", authMiddleware(handleUpload)) + http.HandleFunc("/api/organize", authMiddleware(handleOrganize)) + http.HandleFunc("/api/pages/", authMiddleware(handleComicPages)) + http.HandleFunc("/api/comic/", authMiddleware(handleComicFile)) + http.HandleFunc("/api/cover/", authMiddleware(handleCover)) + http.HandleFunc("/api/tags", authMiddleware(handleTags)) + http.HandleFunc("/api/comic-tags/", authMiddleware(handleComicTags)) + http.HandleFunc("/api/set-password/", authMiddleware(handleSetPassword)) + http.HandleFunc("/api/admin/toggle-registration", authMiddleware(handleToggleRegistration)) + http.HandleFunc("/api/admin/delete-comic/", authMiddleware(handleDeleteComic)) + http.HandleFunc("/", serveUI) + + log.Println("Server starting on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func handleRegister(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodPost { + log.Println("Register: Method not POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !registrationEnabled { + http.Error(w, "Registration disabled", http.StatusForbidden) + return + } + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Register: JSON decode error: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + log.Printf("Register attempt: username=%s", req.Username) + + if req.Username == "" || req.Password == "" { + log.Println("Register: Empty username or password") + http.Error(w, "Username and password required", http.StatusBadRequest) + return + } + + if _, exists := users[req.Username]; exists { + log.Printf("Register: User %s already exists", req.Username) + http.Error(w, "User already exists", http.StatusConflict) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + log.Printf("Register: Bcrypt error: %v", err) + http.Error(w, "Error creating user", http.StatusInternalServerError) + return + } + + // Replace the user creation block (after hash generation): + users[req.Username] = User{ + Username: req.Username, + PasswordHash: string(hash), + IsAdmin: len(users) == 0, // NEW: First user is admin + } + saveUsers() + if len(users) == 1 { // NEW: Init admin config + saveAdminConfig() + registrationEnabled = true + } + // Create per-user directories + userLibrary := filepath.Join("./library", req.Username) + os.MkdirAll(filepath.Join(userLibrary, "Unorganized"), 0755) + os.MkdirAll(filepath.Join("./cache/covers", req.Username), 0755) + + log.Printf("Register: User %s created successfully", req.Username) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "User created"}) +} + +func handleToggleRegistration(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + user := getCurrentUser(r) + if !user.IsAdmin { + http.Error(w, "Admin only", http.StatusForbidden) + return + } + if r.Method == http.MethodPost { + registrationEnabled = !registrationEnabled + saveAdminConfig() + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"enabled": registrationEnabled}) +} + +func getCurrentUser(r *http.Request) User { + cookie, err := r.Cookie("session") + if err != nil { + return User{} // Empty user if no cookie + } + sessionsMutex.RLock() + session, exists := sessions[cookie.Value] + sessionsMutex.RUnlock() + if !exists { + return User{} + } + return users[session.Username] +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodPost { + log.Println("Login: Method not POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Login: JSON decode error: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + log.Printf("Login attempt: username=%s", req.Username) + + user, exists := users[req.Username] + if !exists { + log.Printf("Login: User %s not found", req.Username) + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + log.Printf("Login: Password mismatch for %s", req.Username) + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + token := generateToken() + sessionsMutex.Lock() + sessions[token] = Session{ + Username: req.Username, + ExpiresAt: time.Now().Add(24 * time.Hour), + } + sessionsMutex.Unlock() + + currentUser = req.Username + key := deriveKey(req.Password) + libraryPath = filepath.Join("./library", currentUser) + cachePath = filepath.Join("./cache/covers", currentUser) + os.MkdirAll(filepath.Join(libraryPath, "Unorganized"), 0755) + os.MkdirAll(cachePath, 0755) + + comicsMutex.Lock() + comics = make(map[string]Comic) + comicsMutex.Unlock() + tagsMutex.Lock() + tags = make(map[string]Tag) + tagsMutex.Unlock() + passwordsMutex.Lock() + comicPasswords = make(map[string]string) + passwordsMutex.Unlock() + + loadComics() + loadTags() + loadPasswordsWithKey(key) + currentEncryptionKey = key + scanLibrary() + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + Path: "/", + }) + + log.Printf("Login: User %s logged in successfully", req.Username) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Login successful", + "token": token, + "is_admin": user.IsAdmin, + }) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err == nil { + sessionsMutex.Lock() + delete(sessions, cookie.Value) + sessionsMutex.Unlock() + } + // Clear sensitive data from memory + comicsMutex.Lock() + comics = make(map[string]Comic) + comicsMutex.Unlock() + tagsMutex.Lock() + tags = make(map[string]Tag) + tagsMutex.Unlock() + passwordsMutex.Lock() + comicPasswords = make(map[string]string) + passwordsMutex.Unlock() + currentEncryptionKey = nil + currentUser = "" + libraryPath = "./library" // Reset to default + cachePath = "./cache/covers" + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + HttpOnly: true, + Path: "/", + }) + + json.NewEncoder(w).Encode(map[string]string{"message": "Logged out"}) +} + +func handleComics(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + comicsMutex.RLock() + defer comicsMutex.RUnlock() + + comicList := make([]Comic, 0, len(comics)) + for _, comic := range comics { + comicList = append(comicList, comic) + } + + // Sort by artist, then series, then number + sort.Slice(comicList, func(i, j int) bool { + if comicList[i].Artist != comicList[j].Artist { + return comicList[i].Artist < comicList[j].Artist + } + if comicList[i].Series != comicList[j].Series { + return comicList[i].Series < comicList[j].Series + } + return comicList[i].Number < comicList[j].Number + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(comicList) +} + +func handleUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + r.ParseMultipartForm(100 << 20) // 100 MB max + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "Error retrieving file", http.StatusBadRequest) + return + } + defer file.Close() + + filename := header.Filename + ext := strings.ToLower(filepath.Ext(filename)) + + validExts := map[string]bool{ + ".cbz": true, + } + + if !validExts[ext] { + http.Error(w, "Invalid file type", http.StatusBadRequest) + return + } + + // Save to Unorganized initially + destPath := filepath.Join(libraryPath, "Unorganized", filename) + destFile, err := os.Create(destPath) + if err != nil { + http.Error(w, "Error saving file", http.StatusInternalServerError) + return + } + defer destFile.Close() + + if _, err := io.Copy(destFile, file); err != nil { + http.Error(w, "Error saving file", http.StatusInternalServerError) + return + } + + // Process the comic + comic := processComic(destPath, filename) + + // Must lock/unlock to ensure generateCoverCache sees the comic in the map, + // especially if it finds a password and needs to persist it. + comicsMutex.Lock() + comics[comic.ID] = comic + comicsMutex.Unlock() + + generateCoverCache(&comic) // Pass reference to updated comic struct + + saveComics() + + json.NewEncoder(w).Encode(comic) +} + +func handleDeleteComic(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + user := getCurrentUser(r) + if !user.IsAdmin { + http.Error(w, "Admin only", http.StatusForbidden) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/admin/delete-comic/") + decodedID, _ := url.QueryUnescape(id) + comicsMutex.Lock() + comic, exists := comics[decodedID] + if exists { + os.Remove(comic.FilePath) + for _, tag := range comic.Tags { + updateTagCount(tag, -1) + } + delete(comics, decodedID) + saveComics() + saveTags() + } + comicsMutex.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "Deleted"}) +} + +func handleCover(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/cover/") + decodedID, err := url.QueryUnescape(id) + if err != nil { + decodedID = id + } + + comicsMutex.RLock() + comic, exists := comics[decodedID] + if !exists { + comic, exists = comics[id] + } + comicsMutex.RUnlock() + + if !exists { + http.Error(w, "Comic not found", http.StatusNotFound) + return + } + + // Check cache first + cacheFile := filepath.Join(cachePath, comic.ID+".jpg") + if _, err := os.Stat(cacheFile); err == nil { + http.ServeFile(w, r, cacheFile) + return + } + + // Generate on-the-fly + if comic.FileType == ".cbz" { + serveCoverFromCBZ(w, r, comic) + } else { + http.Error(w, "Cover not available", http.StatusNotFound) + } +} + +func handleTags(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.Method { + case http.MethodGet: + tagsMutex.RLock() + tagList := make([]Tag, 0, len(tags)) + for _, tag := range tags { + tagList = append(tagList, tag) + } + tagsMutex.RUnlock() + + sort.Slice(tagList, func(i, j int) bool { + return tagList[i].Name < tagList[j].Name + }) + + json.NewEncoder(w).Encode(tagList) + + case http.MethodPost: + var req struct { + Name string `json:"name"` + Color string `json:"color"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "Tag name required", http.StatusBadRequest) + return + } + + if req.Color == "" { + req.Color = "#1f6feb" + } + + tagsMutex.Lock() + tags[req.Name] = Tag{ + Name: req.Name, + Color: req.Color, + Count: 0, + } + tagsMutex.Unlock() + + saveTags() + json.NewEncoder(w).Encode(tags[req.Name]) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleComicTags(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/comic-tags/"), "/") + if len(parts) == 0 { + http.Error(w, "Comic ID required", http.StatusBadRequest) + return + } + + id := parts[0] + decodedID, err := url.QueryUnescape(id) + if err != nil { + decodedID = id + } + + comicsMutex.Lock() + defer comicsMutex.Unlock() + + comic, exists := comics[decodedID] + if !exists { + comic, exists = comics[id] + if !exists { + http.Error(w, "Comic not found", http.StatusNotFound) + return + } + } + + switch r.Method { + case http.MethodPost: + var req struct { + Tag string `json:"tag"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Add tag if not already present + found := false + for _, t := range comic.Tags { + if t == req.Tag { + found = true + break + } + } + + if !found { + comic.Tags = append(comic.Tags, req.Tag) + comics[decodedID] = comic + updateTagCount(req.Tag, 1) + saveComics() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(comic) + + case http.MethodDelete: + if len(parts) < 2 { + http.Error(w, "Tag required", http.StatusBadRequest) + return + } + + tagToRemove, _ := url.QueryUnescape(parts[1]) + newTags := []string{} + removed := false + + for _, t := range comic.Tags { + if t != tagToRemove { + newTags = append(newTags, t) + } else { + removed = true + } + } + + if removed { + comic.Tags = newTags + comics[decodedID] = comic + updateTagCount(tagToRemove, -1) + saveComics() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(comic) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleSetPassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/set-password/") + decodedID, err := url.QueryUnescape(id) + if err != nil { + decodedID = id + } + + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + comicsMutex.Lock() + comic, exists := comics[decodedID] + if !exists { + comic, exists = comics[id] + } + comicsMutex.Unlock() + + if !exists { + http.Error(w, "Comic not found", http.StatusNotFound) + return + } + + if !comic.Encrypted { + http.Error(w, "Comic not encrypted", http.StatusBadRequest) + return + } + + // Verify password by trying to open ComicInfo.xml + yr, err := yzip.OpenReader(comic.FilePath) + if err != nil { + http.Error(w, "Error reading comic", http.StatusInternalServerError) + return + } + defer yr.Close() + + valid := false + for _, f := range yr.File { + if strings.ToLower(f.Name) == "comicinfo.xml" { + f.SetPassword(req.Password) + rc, err := f.Open() + if err != nil { + break + } + data, readErr := io.ReadAll(rc) + rc.Close() + if readErr != nil || len(data) == 0 { + break + } + // Quick XML check + var info ComicInfo + if xml.Unmarshal(data, &info) == nil { + valid = true + } + break + } + } + + if !valid { + http.Error(w, "Invalid password", http.StatusBadRequest) + return + } + + // Set and save + comicsMutex.Lock() + c := comics[decodedID] + c.Password = req.Password + c.HasPassword = true + comics[decodedID] = c + comicsMutex.Unlock() + + passwordsMutex.Lock() + comicPasswords[decodedID] = req.Password + passwordsMutex.Unlock() + savePasswords() + + // Extract metadata now that password is known + comicsMutex.Lock() + c = comics[decodedID] + extractCBZMetadata(&c) + // Organize comic based on extracted metadata +if c.Artist != "Unknown" || c.StoryArc != "" { + inker := sanitizeFilename(c.Artist) + storyArc := sanitizeFilename(c.StoryArc) + if inker == "" { + inker = "Unknown" + } + if storyArc == "" { + storyArc = "No_StoryArc" + } + newDir := filepath.Join(libraryPath, inker, storyArc) + os.MkdirAll(newDir, 0755) + filename := filepath.Base(c.FilePath) + newPath := filepath.Join(newDir, filename) + if newPath != c.FilePath { + if err := os.Rename(c.FilePath, newPath); err == nil { + c.FilePath = newPath + } else { + log.Printf("Failed to move comic %s to %s: %v", c.ID, newPath, err) + } + } +} + +// Update tags counts for newly extracted tags +tagsMutex.Lock() +for _, tag := range c.Tags { + if tagData, exists := tags[tag]; exists { + tagData.Count++ + tags[tag] = tagData + } else { + tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1} + } +} +tagsMutex.Unlock() +comics[decodedID] = c +comicsMutex.Unlock() + + saveComics() + saveTags() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Password set successfully"}) +} + +func handleComicFile(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/comic/"), "/") + id := parts[0] + + decodedID, err := url.QueryUnescape(id) + if err != nil { + log.Printf("Error decoding ID: %v", err) + decodedID = id + } + + comicsMutex.RLock() + comic, exists := comics[decodedID] + if !exists { + comic, exists = comics[id] + } + comicsMutex.RUnlock() + + if !exists { + log.Printf("Comic file not found for ID: %s or %s", decodedID, id) + http.Error(w, "Comic not found", http.StatusNotFound) + return + } + + if len(parts) > 1 && parts[1] == "page" && len(parts) > 2 { + pageNum := parts[2] + serveComicPage(w, r, comic, pageNum) + return + } + + http.ServeFile(w, r, comic.FilePath) +} + +func serveComicPage(w http.ResponseWriter, r *http.Request, comic Comic, pageNum string) { + if comic.FileType != ".cbz" { + http.Error(w, "Only CBZ format supported for page viewing", http.StatusBadRequest) + return + } + + var pageIdx int + fmt.Sscanf(pageNum, "%d", &pageIdx) + + yr, err := yzip.OpenReader(comic.FilePath) + if err != nil { + log.Printf("Error opening CBZ with yeka/zip: %v", err) + serveComicPageStandard(w, r, comic, pageIdx) + return + } + defer yr.Close() + + var imageFiles []*yzip.File + for _, f := range yr.File { + if f.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(f.Name)) + // Broad image format support + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".avif" || + ext == ".jxl" || ext == ".jp2" || ext == ".webp" || ext == ".gif" || ext == ".bmp" { + imageFiles = append(imageFiles, f) + } + } + + sort.Slice(imageFiles, func(i, j int) bool { + return imageFiles[i].Name < imageFiles[j].Name + }) + + if pageIdx < 0 || pageIdx >= len(imageFiles) { + http.Error(w, "Page not found", http.StatusNotFound) + return + } + + targetFile := imageFiles[pageIdx] + + // Password handling + if targetFile.IsEncrypted() { + if comic.Password != "" { + targetFile.SetPassword(comic.Password) + } else { + http.Error(w, "Comic requires password (contact admin or re-open reader)", http.StatusUnauthorized) + return + } + } + + rc, err := targetFile.Open() + if err != nil { + log.Printf("Error opening page file: %v", err) + http.Error(w, "Error reading page - file may be encrypted", http.StatusInternalServerError) + return + } + defer rc.Close() + + imageData, err := io.ReadAll(rc) + if err != nil { + log.Printf("Error reading image data: %v", err) + http.Error(w, "Error reading page", http.StatusInternalServerError) + return + } + + ext := strings.ToLower(filepath.Ext(targetFile.Name)) + contentType := getContentType(ext) + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(imageData) +} + +func serveComicPageStandard(w http.ResponseWriter, r *http.Request, comic Comic, pageIdx int) { + zipReader, err := zip.OpenReader(comic.FilePath) + if err != nil { + http.Error(w, "Error reading comic", http.StatusInternalServerError) + return + } + defer zipReader.Close() + + var imageFiles []*zip.File + for _, f := range zipReader.File { + if f.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(f.Name)) + // Broad image format support + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".avif" || + ext == ".jxl" || ext == ".jp2" || ext == ".webp" || ext == ".gif" || ext == ".bmp" { + imageFiles = append(imageFiles, f) + } + } + + sort.Slice(imageFiles, func(i, j int) bool { + return imageFiles[i].Name < imageFiles[j].Name + }) + + if pageIdx < 0 || pageIdx >= len(imageFiles) { + http.Error(w, "Page not found", http.StatusNotFound) + return + } + + targetFile := imageFiles[pageIdx] + rc, err := targetFile.Open() + if err != nil { + http.Error(w, "Error reading page", http.StatusInternalServerError) + return + } + defer rc.Close() + + imageData, err := io.ReadAll(rc) + if err != nil { + http.Error(w, "Error reading page", http.StatusInternalServerError) + return + } + + ext := strings.ToLower(filepath.Ext(targetFile.Name)) + contentType := getContentType(ext) + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(imageData) +} + +func extractCBZMetadataStandard(comic *Comic) { + r, err := zip.OpenReader(comic.FilePath) + if err != nil { + return + } + defer r.Close() + + for _, f := range r.File { + if strings.ToLower(f.Name) == "comicinfo.xml" { + rc, err := f.Open() + if err != nil { + continue + } + + data, err := io.ReadAll(rc) + rc.Close() + + if err != nil { + continue + } + + var info ComicInfo + if err := xml.Unmarshal(data, &info); err == nil { + comic.Title = info.Title + comic.Series = info.Series + comic.StoryArc = info.StoryArc + comic.Number = info.Number + comic.Publisher = info.Publisher + comic.Year = info.Year + comic.PageCount = info.PageCount + + // Extract tags from TagsXml first, then fallback to Genre + tagsSource := info.TagsXml + if tagsSource == "" { + tagsSource = info.Genre + } + + if tagsSource != "" { + tags := strings.FieldsFunc(tagsSource, func(r rune) bool { + return r == ',' || r == ';' || r == '|' + }) + comic.Tags = make([]string, 0, len(tags)) + for _, tag := range tags { + if t := strings.TrimSpace(tag); t != "" { + comic.Tags = append(comic.Tags, t) + } + } + } + + if info.Inker != "" { + comic.Artist = info.Inker + } else if info.Artist != "" { + comic.Artist = info.Artist + } else if info.Writer != "" { + comic.Artist = info.Writer + } + } + break + } + } +} + +func serveCoverFromCBZ(w http.ResponseWriter, r *http.Request, comic Comic) { + yr, err := yzip.OpenReader(comic.FilePath) + if err != nil { + http.Error(w, "Error reading comic", http.StatusInternalServerError) + return + } + defer yr.Close() + + var imageFiles []*yzip.File + for _, f := range yr.File { + if f.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(f.Name)) + // FIX 2: Expanded image types for serving covers + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" || ext == ".avif" || ext == ".jxl" || ext == ".webp" || ext == ".bmp" || ext == ".jp2" { + imageFiles = append(imageFiles, f) + } + } + + if len(imageFiles) == 0 { + http.Error(w, "No cover found", http.StatusNotFound) + return + } + + sort.Slice(imageFiles, func(i, j int) bool { + return imageFiles[i].Name < imageFiles[j].Name + }) + + coverFile := imageFiles[0] + + // Password handling + if coverFile.IsEncrypted() { + if comic.Password != "" { + coverFile.SetPassword(comic.Password) + } else { + http.Error(w, "Comic requires password (contact admin or re-open reader)", http.StatusUnauthorized) + return + } + } + + rc, err := coverFile.Open() + if err != nil { + log.Printf("Error opening cover for ID %s: %v", comic.ID, err) + http.Error(w, "Error reading cover - file may be encrypted", http.StatusInternalServerError) + return + } + defer rc.Close() + + imageData, err := io.ReadAll(rc) + if err != nil { + http.Error(w, "Error reading cover", http.StatusInternalServerError) + return + } + + ext := strings.ToLower(filepath.Ext(coverFile.Name)) + w.Header().Set("Content-Type", getContentType(ext)) + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(imageData) +} + +func handleComicPages(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/pages/") + decodedID, err := url.QueryUnescape(id) + if err != nil { + decodedID = id + } + + comicsMutex.RLock() + comic, exists := comics[decodedID] + if !exists { + comic, exists = comics[id] + } + comicsMutex.RUnlock() + + if !exists { + http.Error(w, "Comic not found", http.StatusNotFound) + return + } + + if comic.FileType != ".cbz" { + json.NewEncoder(w).Encode(map[string]interface{}{ + "page_count": 0, + "pages": []string{}, + }) + return + } + + yr, err := yzip.OpenReader(comic.FilePath) + if err != nil { + http.Error(w, "Error reading comic", http.StatusInternalServerError) + return + } + defer yr.Close() + + var imageFiles []string + needsPassword := comic.Encrypted && comic.Password == "" && !comic.HasPassword + for _, f := range yr.File { + if f.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(f.Name)) + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".avif" || + ext == ".jxl" || ext == ".jp2" || ext == ".webp" || ext == ".gif" || ext == ".bmp" { + if f.IsEncrypted() && needsPassword { + needsPassword = true + } + imageFiles = append(imageFiles, f.Name) + } + } + + sort.Strings(imageFiles) + + data := map[string]interface{}{ + "page_count": len(imageFiles), + "pages": imageFiles, + } + if needsPassword { + data["needs_password"] = true + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +func handleOrganize(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + ComicID string `json:"comic_id"` + Inker string `json:"inker"` + StoryArc string `json:"story_arc"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + comicsMutex.Lock() + defer comicsMutex.Unlock() + + comic, exists := comics[req.ComicID] + if !exists { + http.Error(w, "Comic not found", http.StatusNotFound) + return + } + + inker := sanitizeFilename(req.Inker) + storyArc := sanitizeFilename(req.StoryArc) + if inker == "" { + inker = "Unknown" + } + if storyArc == "" { + storyArc = "No_StoryArc" + } + + newDir := filepath.Join(libraryPath, inker, storyArc) + os.MkdirAll(newDir, 0755) + + newPath := filepath.Join(newDir, filepath.Base(comic.FilePath)) + if err := os.Rename(comic.FilePath, newPath); err != nil { + http.Error(w, "Error organizing comic", http.StatusInternalServerError) + return + } + + comic.FilePath = newPath + comic.Artist = req.Inker + comic.StoryArc = req.StoryArc + comics[req.ComicID] = comic + + saveComics() + json.NewEncoder(w).Encode(comic) +} + +func processComic(filePath, filename string) Comic { + comic := Comic{ + ID: generateToken(), + Filename: filename, + FilePath: filePath, + FileType: strings.ToLower(filepath.Ext(filename)), + UploadedAt: time.Now(), + Artist: "Unknown", + Tags: []string{}, + } + + if comic.FileType == ".cbz" { + extractCBZMetadata(&comic) + // Register extracted tags in global tags map + tagsMutex.Lock() + for _, tag := range comic.Tags { + if _, exists := tags[tag]; !exists { + tags[tag] = Tag{ + Name: tag, + Color: "#1f6feb", // Default color + Count: 0, + } + } + tagData := tags[tag] + tagData.Count++ + tags[tag] = tagData + } + tagsMutex.Unlock() + saveTags() + + // Create folder structure based on Inker and StoryArc + if comic.Artist != "Unknown" || comic.StoryArc != "" { + inker := sanitizeFilename(comic.Artist) + storyArc := sanitizeFilename(comic.StoryArc) + if inker == "" { + inker = "Unknown" + } + if storyArc == "" { + storyArc = "No_StoryArc" + } + newDir := filepath.Join(libraryPath, inker, storyArc) + os.MkdirAll(newDir, 0755) + + newPath := filepath.Join(newDir, filename) + if newPath != filePath { + if err := os.Rename(filePath, newPath); err == nil { + comic.FilePath = newPath + } + } + } + } + + parentDir := filepath.Dir(filePath) + if filepath.Base(parentDir) != "Unorganized" { + dirName := filepath.Base(filepath.Dir(parentDir)) + comic.Artist = dirName + } + + return comic +} + +func generateCoverCache(comic *Comic) { + if comic.FileType != ".cbz" { + return + } + + cacheFile := filepath.Join(cachePath, comic.ID+".jpg") + if _, err := os.Stat(cacheFile); err == nil { + return + } + + yr, err := yzip.OpenReader(comic.FilePath) + if err != nil { + return + } + defer yr.Close() + + var imageFiles []*yzip.File + for _, f := range yr.File { + if f.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(f.Name)) + // FIX 2: Expanded image types for cover caching + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" || ext == ".avif" || ext == ".jxl" || ext == ".webp" || ext == ".bmp" || ext == ".jp2" { + imageFiles = append(imageFiles, f) + } + } + + if len(imageFiles) == 0 { + return + } + + sort.Slice(imageFiles, func(i, j int) bool { + return imageFiles[i].Name < imageFiles[j].Name + }) + + coverFile := imageFiles[0] + + // Password handling + if coverFile.IsEncrypted() { + if comic.Password != "" { + coverFile.SetPassword(comic.Password) + } else { + log.Printf("Failed to open cover file for ID %s. File encrypted or corrupted.", comic.ID) + return + } + } + + rc, err := coverFile.Open() + if err != nil { + log.Printf("Failed to open cover file for ID %s. File encrypted or corrupted. %v", comic.ID, err) + return + } + defer rc.Close() + + out, err := os.Create(cacheFile) + if err != nil { + return + } + defer out.Close() + + io.Copy(out, rc) +} + +func extractCBZMetadata(comic *Comic) { + yr, err := yzip.OpenReader(comic.FilePath) + if err != nil { + extractCBZMetadataStandard(comic) + return + } + defer yr.Close() + + isEncrypted := false + for _, f := range yr.File { + if f.IsEncrypted() { + isEncrypted = true + break + } + } + comic.Encrypted = isEncrypted + comic.HasPassword = false // Default until proven + + if !isEncrypted { + // Use standard extraction if not encrypted + extractCBZMetadataStandard(comic) + comic.HasPassword = true // No password needed + return + } + + // Collect unique known passwords from other comics + passwordsMutex.RLock() + knownPwds := make(map[string]bool) + for _, pwd := range comicPasswords { + if pwd != "" { + knownPwds[pwd] = true + } + } + passwordsMutex.RUnlock() + + foundPwd := "" + for _, f := range yr.File { + if strings.ToLower(f.Name) == "comicinfo.xml" { + var data []byte + var readErr error + + if len(knownPwds) > 0 { + // Try known passwords + for pwd := range knownPwds { + f.SetPassword(pwd) + rc, err := f.Open() + if err != nil { + continue + } + data, readErr = io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + if readErr == nil && len(data) > 0 { + foundPwd = pwd + break + } + } + } + + if foundPwd != "" { + // Success: persist + comic.Password = foundPwd + comic.HasPassword = true + passwordsMutex.Lock() + comicPasswords[comic.ID] = foundPwd + passwordsMutex.Unlock() + savePasswords() + } else if !isEncrypted { + // Fallback for non-encrypted + rc, err := f.Open() + if err != nil { + continue + } + data, readErr = io.ReadAll(rc) + rc.Close() + } + + if readErr != nil || len(data) == 0 { + continue + } + + var info ComicInfo + if err := xml.Unmarshal(data, &info); err == nil { + comic.Title = info.Title + comic.Series = info.Series + comic.StoryArc = info.StoryArc + comic.Number = info.Number + comic.Publisher = info.Publisher + comic.Year = info.Year + comic.PageCount = info.PageCount + + // Extract tags from TagsXml first, then fallback to Genre + tagsSource := info.TagsXml + if tagsSource == "" { + tagsSource = info.Genre + } + + if tagsSource != "" { + tags := strings.FieldsFunc(tagsSource, func(r rune) bool { + return r == ',' || r == ';' || r == '|' + }) + comic.Tags = make([]string, 0, len(tags)) + for _, tag := range tags { + if t := strings.TrimSpace(tag); t != "" { + comic.Tags = append(comic.Tags, t) + } + } + } + + if info.Inker != "" { + comic.Artist = info.Inker + } else if info.Artist != "" { + comic.Artist = info.Artist + } else if info.Writer != "" { + comic.Artist = info.Writer + } + } + break + } + } +} + +func scanLibrary() { + // Create a map to track existing file paths for quick lookup + comicsMutex.RLock() + existingPaths := make(map[string]string) + for id, comic := range comics { + existingPaths[comic.FilePath] = id + } + comicsMutex.RUnlock() + + filepath.Walk(libraryPath, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".cbz" { + return nil + } + + comicsMutex.RLock() + id, exists := existingPaths[path] + comicsMutex.RUnlock() + + if exists { + // Verify cache exists for this comic + comic := comics[id] + cacheFile := filepath.Join(cachePath, comic.ID+".jpg") + if _, err := os.Stat(cacheFile); os.IsNotExist(err) && comic.FileType == ".cbz" { + // Generate cache only if it doesn't exist + comicsMutex.RLock() + c := comics[id] + comicsMutex.RUnlock() + generateCoverCache(&c) + comicsMutex.Lock() + comics[id] = c // Update with any new password found + comicsMutex.Unlock() + } + return nil + } + + // Process new comic + comic := processComic(path, info.Name()) + comicsMutex.Lock() + comics[comic.ID] = comic + comicsMutex.Unlock() + + // Generate cover cache for new comic + comicsMutex.RLock() + c := comics[comic.ID] + comicsMutex.RUnlock() + generateCoverCache(&c) + comicsMutex.Lock() + comics[comic.ID] = c // Write back potential password found + comicsMutex.Unlock() + + return nil + }) + + // Clean up comics that no longer exist + comicsMutex.Lock() + for id, comic := range comics { + if _, err := os.Stat(comic.FilePath); os.IsNotExist(err) { + // Remove tags associated with this comic + for _, tag := range comic.Tags { + updateTagCount(tag, -1) + } + delete(comics, id) + } + } + comicsMutex.Unlock() + + saveComics() + saveTags() +} + +func authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + sessionsMutex.RLock() + session, exists := sessions[cookie.Value] + sessionsMutex.RUnlock() + + if !exists || time.Now().After(session.ExpiresAt) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + next(w, r) + } +} + +// Replace loadUsers(): +func loadUsers() { + data, err := os.ReadFile("etc/users.json") + if err != nil { + log.Printf("Error reading users.json: %v", err) + return + } + if err := json.Unmarshal(data, &users); err != nil { + log.Printf("Error unmarshaling users: %v", err) + } + + // Always load admin config to set registrationEnabled + adminData, err := os.ReadFile("etc/admin.json") + if err == nil && len(adminData) > 0 { + var adminConfig struct{ RegistrationEnabled bool } + if err := json.Unmarshal(adminData, &adminConfig); err == nil { + registrationEnabled = adminConfig.RegistrationEnabled + } else { + log.Printf("Error unmarshaling admin.json: %v", err) + } + } +} + +func saveUsers() { + data, _ := json.MarshalIndent(users, "", " ") + os.WriteFile("etc/users.json", data, 0644) +} + +// Add new function after saveUsers(): +func saveAdminConfig() { + config := struct{ RegistrationEnabled bool }{RegistrationEnabled: registrationEnabled} + data, _ := json.MarshalIndent(config, "", " ") + os.WriteFile("etc/admin.json", data, 0644) +} + +func loadTags() { + data, err := os.ReadFile(filepath.Join(libraryPath, "tags.json")) + if err != nil { + return + } + tagsMutex.Lock() + defer tagsMutex.Unlock() + json.Unmarshal(data, &tags) +} + +func saveTags() { + data, _ := json.MarshalIndent(tags, "", " ") + os.WriteFile(filepath.Join(libraryPath, "tags.json"), data, 0644) +} + +func saveComics() { + data, _ := json.MarshalIndent(comics, "", " ") + os.WriteFile(filepath.Join(libraryPath, "comics.json"), data, 0644) +} + +func loadComics() { + data, err := os.ReadFile(filepath.Join(libraryPath, "comics.json")) + if err != nil { + return + } + comicsMutex.Lock() + defer comicsMutex.Unlock() + json.Unmarshal(data, &comics) +} + +func loadPasswordsWithKey(key []byte) { + data, err := os.ReadFile(filepath.Join(libraryPath, "passwords.json")) + if err != nil { + log.Printf("No passwords file for user %s, starting fresh", currentUser) + return + } + + b64data := strings.TrimSpace(string(data)) + encrypted, err := base64.StdEncoding.DecodeString(b64data) + if err != nil { + log.Printf("Failed to decode passwords.json: %v", err) + return + } + + decrypted, err := decryptAES(encrypted, key) + if err != nil { + log.Printf("Failed to decrypt passwords: %v", err) + return + } + + passwordsMutex.Lock() + defer passwordsMutex.Unlock() + if err := json.Unmarshal(decrypted, &comicPasswords); err != nil { + log.Printf("Failed to unmarshal passwords: %v", err) + return + } + + // Restore Password and HasPassword in comics map + comicsMutex.Lock() + defer comicsMutex.Unlock() + for id, pwd := range comicPasswords { + if c, exists := comics[id]; exists { + c.Password = pwd + c.HasPassword = (pwd != "") + comics[id] = c + } + } +} + +func savePasswords() { + if len(currentEncryptionKey) == 0 { + log.Println("No encryption key set, skipping save") + return + } + + passwordsMutex.Lock() + defer passwordsMutex.Unlock() + data, err := json.MarshalIndent(comicPasswords, "", " ") + if err != nil { + log.Printf("Failed to marshal passwords: %v", err) + return + } + + encrypted, err := encryptAES(data, currentEncryptionKey) + if err != nil { + log.Printf("Failed to encrypt passwords: %v", err) + return + } + + b64 := base64.StdEncoding.EncodeToString(encrypted) + if err := os.WriteFile(filepath.Join(libraryPath, "passwords.json"), []byte(b64), 0644); err != nil { + log.Printf("Failed to write passwords.json for user %s: %v", currentUser, err) + } +} + +func updateTagCount(tagName string, delta int) { + tagsMutex.Lock() + defer tagsMutex.Unlock() + + if tag, exists := tags[tagName]; exists { + tag.Count += delta + if tag.Count < 0 { + tag.Count = 0 + } + tags[tagName] = tag + saveTags() + } +} + +func generateToken() string { + hash := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + return base64.URLEncoding.EncodeToString(hash[:]) +} + +func sanitizeFilename(filename string) string { + // Replace spaces explicitly with underscores + filename = strings.ReplaceAll(filename, " ", "_") + // Replace any character that isn't alphanumeric, hyphen, or underscore with underscore + reg, _ := regexp.Compile("[^a-zA-Z0-9-_]+") + sanitized := reg.ReplaceAllString(filename, "_") + // Remove leading/trailing underscores + sanitized = strings.Trim(sanitized, "_") + if sanitized == "" { + return "Unknown" + } + return sanitized +} + +func getContentType(ext string) string { + switch ext { + case ".png": + return "image/png" + case ".webp": + return "image/webp" + case ".avif": + return "image/avif" + case ".jxl": + return "image/jxl" + case ".jp2": + return "image/jp2" + case ".gif": + return "image/gif" + case ".bmp": + return "image/bmp" + default: + return "image/jpeg" + } +} + +func deriveKey(seed string) []byte { + hash := sha256.Sum256([]byte(seed)) + return hash[:32] +} + +func isPlaintext(data []byte) bool { + if len(data) < 4 { + return true + } + + if len(data) >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return true + } + if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return true + } + if len(data) >= 3 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 { + return true + } + if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && + data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 { + return true + } + if data[0] == 0x3C { + return true + } + + return false +} + +func decryptAES(data []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + if len(data) < aes.BlockSize { + return nil, fmt.Errorf("ciphertext too short") + } + + iv := data[:aes.BlockSize] + data = data[aes.BlockSize:] + + stream := cipher.NewCFBDecrypter(block, iv) + stream.XORKeyStream(data, data) + + return data, nil +} + +func encryptAES(plaintext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // Generate random IV + iv := make([]byte, aes.BlockSize) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + + // Create the cipher stream + stream := cipher.NewCFBEncrypter(block, iv) + + // Encrypt the plaintext + ciphertext := make([]byte, len(plaintext)) + stream.XORKeyStream(ciphertext, plaintext) + + // Prepend IV to ciphertext + return append(iv, ciphertext...), nil +} + +func serveUI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(getHTML())) +} + +func getHTML() string { + return ` + + + + + Gopherbook + + + +
+
+

Gopherbook

+ +
+
+ +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+ +
+ + +
+ + +
+ +
+
+
Loading...
+
+ + + / 0 + + + +
+
+
+ Comic page +
+ + + +
+
+
+ +
+ +
+ +
+
+
+
🔒 Password Required
+
This comic is encrypted
+
+ + + +
+ + +
+ +
+ + +
+
+
+ + + + + +` +} diff --git a/bash-scripts/comic-page-number.sh b/bash-scripts/comic-page-number.sh new file mode 100755 index 0000000..d38be72 --- /dev/null +++ b/bash-scripts/comic-page-number.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# --- Configuration --- +# Set the directory containing your comic book images +IMAGE_DIR="./" + +# Set the name of the file to save the output to +OUTPUT_FILE="pages_xml_output.txt" + +# --- Script Start --- + +# Check if the image directory exists +if [ ! -d "$IMAGE_DIR" ]; then + echo "Error: Directory '$IMAGE_DIR' not found." >&2 + echo "Please create the directory and place your images inside, or update the IMAGE_DIR variable." >&2 + exit 1 +fi + +# Ensure the output file is clear or create it +> "$OUTPUT_FILE" + +# Start the tag +echo " " >> "$OUTPUT_FILE" + +# Initialize a counter for the attribute +page_counter=0 + +# Use 'find' to get a list of image files, sort them numerically (important for comic order) +# Adjust the pattern (*.jpg|*.jpeg|*.png) to match your file types if necessary +find "$IMAGE_DIR" -maxdepth 1 -type f -regex ".*\.\(jpg\|jpeg\|png\|jxl\|avif\)$" | sort -V | while read -r image_path; do + + # 1. Get the ImageSize in bytes + # Use 'stat' with a format to get the size in bytes (the command might differ slightly on non-Linux systems like macOS) + # Linux (GNU stat): %s + # macOS (BSD stat): %z + + # Simple cross-platform attempt (often works): + # FALLBACK: If the 'stat' command is complex or fails, a simple 'wc -c' (byte count) can be used. + # We will try 'stat' for better compatibility with typical setups. + + file_size_bytes=$(stat -c%s "$image_path" 2>/dev/null || wc -c < "$image_path") + + # 2. Determine if it's the first page (for Type="FrontCover") + if [ "$page_counter" -eq 0 ]; then + # This is the cover page (Page Image="0") + xml_line=" " + else + # Subsequent pages + xml_line=" " + fi + + # 3. Print the generated XML line + echo "$xml_line" >> "$OUTPUT_FILE" + + # 4. Increment the counter + ((page_counter++)) + +done + +# End the tag +echo " " >> "$OUTPUT_FILE" + +echo "---" +echo "✅ Script completed." +echo "Generated $page_counter entries and saved the output to: $OUTPUT_FILE" +echo "You can now paste the content of '$OUTPUT_FILE' into your ComicInfo.xml file." +echo "---" + +# Optional: Display the content of the generated file +cat "$OUTPUT_FILE" diff --git a/bash-scripts/make-cbz-imagemagick.sh b/bash-scripts/make-cbz-imagemagick.sh new file mode 100755 index 0000000..f2e100c --- /dev/null +++ b/bash-scripts/make-cbz-imagemagick.sh @@ -0,0 +1,5 @@ +#!/bin/sh +user="$(gpg2 -qd /home/moo/test/gopherbook/moo.pass.asc)" +EXTEN="jpg" +mkdir -p ./cbzs +7z a -tzip -mem=AES256 -mx=9 ./cbzs/$1-others.cbz -p *.$EXTEN ComicInfo.xml diff --git a/bash-scripts/run.sh b/bash-scripts/run.sh new file mode 100755 index 0000000..68ebbdf --- /dev/null +++ b/bash-scripts/run.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +IMAGE_NAME="localhost/gopherbook:latest" +CONTAINER_NAME="gopherbook" + +echo "Building new image: $IMAGE_NAME..." +podman build --force-rm -t "$IMAGE_NAME" . + +if [ $? -ne 0 ]; then + echo "Image build failed. Exiting script." + exit 1 +fi + +# Ensure directories exist with correct permissions +mkdir -p ./library ./cache ./etc + +if podman container exists "$CONTAINER_NAME"; then + echo "Container '$CONTAINER_NAME' already exists. Stopping and removing it..." + podman stop "$CONTAINER_NAME" + podman rm "$CONTAINER_NAME" +fi + +echo "Starting new container from image: $IMAGE_NAME..." +podman run -d --name "$CONTAINER_NAME" --memory=256m --restart unless-stopped \ + -p 12010:8080 -v ./library:/app/library -v ./cache:/app/cache -v ./etc:/app/etc "$IMAGE_NAME" + +if [ $? -ne 0 ]; then + echo "Failed to start new container. Exiting script." + exit 1 +fi + +echo "Cleaning up old images..." +podman image prune --force + +echo "Update and cleanup complete!" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..44a741e --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module gobook + +go 1.25.2 + +require ( + github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 + golang.org/x/crypto v0.43.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47120ee --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M= +github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= diff --git a/xml-template/ComicInfo.xml b/xml-template/ComicInfo.xml new file mode 100644 index 0000000..751697e --- /dev/null +++ b/xml-template/ComicInfo.xml @@ -0,0 +1,35 @@ + + + Adults Only 18+ + No + CBZ + Artist Name + Artist Name + Artist Name + Artist Name + Publisher Name + en + No + CBZ created by nobodyatall@cock.li + Blood, Gore, Comedy, Dark Comedy, Mature, Leading Ladies, Fantasy + person who scanned comic + 5 + + + + + + + + Main Title + Sub Title + 1 + Summary of book + Main Title: Sub Title + 2017 + 9 + 11 + 1 + 1 + 1 +