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
-
-[](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
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
-
-
-
-
-
-
-
-
-
- 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
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
-