diff --git a/.containerignore b/.containerignore deleted file mode 100644 index 3a14218..0000000 --- a/.containerignore +++ /dev/null @@ -1,11 +0,0 @@ -.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 deleted file mode 100644 index 77c0f5f..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1 +0,0 @@ -By interacting with this repository you agree that any emotional damage is your own damn fault. diff --git a/Containerfile b/Containerfile deleted file mode 100644 index 5ef177e..0000000 --- a/Containerfile +++ /dev/null @@ -1,32 +0,0 @@ -# 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 deleted file mode 100644 index b35358f..0000000 --- a/LICENSE +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 8adf6e4..0000000 --- a/README.md +++ /dev/null @@ -1,170 +0,0 @@ -# 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 deleted file mode 100644 index 4c8ed0c..0000000 --- a/app/gopherbook/main.go +++ /dev/null @@ -1,3328 +0,0 @@ -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 deleted file mode 100755 index d38be72..0000000 --- a/bash-scripts/comic-page-number.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/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 deleted file mode 100755 index f2e100c..0000000 --- a/bash-scripts/make-cbz-imagemagick.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/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 deleted file mode 100755 index 68ebbdf..0000000 --- a/bash-scripts/run.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/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 deleted file mode 100644 index 44a741e..0000000 --- a/go.mod +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 47120ee..0000000 --- a/go.sum +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 751697e..0000000 --- a/xml-template/ComicInfo.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - 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 -