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
+
+[](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
+[](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
+
+
+
+
+
+
+
+
+
+## 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
+
+
+
+
+
+
+
+
+
+ Login
+ Register
+
+
+
+
+
+
+
+
+
+
+
+
Upload Comic
+
+ Select File (CBZ)
+
+
+
Upload
+
+
+
+
+
Library
+
+
Admin Panel
+
+ Registration: Enabled
+ Toggle
+
+
+ Delete Comic ID:
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+ −
+ ⊙
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enter Password
+
+
+
+
+ Cancel
+ Unlock
+
+
+
+
+
+
+
+
+`
+}
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
+