diff --git a/Containerfile b/Containerfile index 5ef177e..4412cdc 100644 --- a/Containerfile +++ b/Containerfile @@ -1,32 +1,40 @@ # Build stage -FROM golang:bookworm AS builder +FROM golang:alpine AS builder +RUN apk add --no-cache \ + musl-dev \ + gcc \ + wget \ + xz \ + git -# 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 +RUN wget https://github.com/upx/upx/releases/download/v5.0.2/upx-5.0.2-amd64_linux.tar.xz && \ + 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 ./ +COPY go.mod go.sum ./ + 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 +# Build with CGO and increased WASM memory limits +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \ + -a \ + -ldflags="-s -w -linkmode external -extldflags '-static' -X main.GOMEMLIMIT=512MiB -X runtime.defaultGOGC=50" \ + -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/README.md b/README.md index 8adf6e4..d9c4a95 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ It is designed for people who want full control over their digital comic collect ## Features - Upload & read `.cbz` (ZIP-based) comics directly in the browser +- Supports 8 Megapixel images at 512MB memory limits + - (increase in bash script for higher Megapixels or remove the limitation if you don't care) - 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) diff --git a/app/gopherbook/main.go b/app/gopherbook/main.go index 4c8ed0c..b9ab47b 100644 --- a/app/gopherbook/main.go +++ b/app/gopherbook/main.go @@ -1,11 +1,11 @@ package main import ( - "archive/zip" "crypto/aes" "crypto/cipher" - "crypto/sha256" "crypto/rand" + "crypto/sha256" + "embed" "encoding/base64" "encoding/json" "encoding/xml" @@ -15,17 +15,28 @@ import ( "net/http" "net/url" "os" - "regexp" "path/filepath" + "regexp" "sort" "strings" "sync" "time" + "runtime" + "runtime/debug" + "image" + "image/jpeg" + _ "image/gif" + _ "image/png" + "github.com/nfnt/resize" + _ "github.com/gen2brain/avif" "golang.org/x/crypto/bcrypt" yzip "github.com/yeka/zip" ) +//go:embed templates/index.html +var templateFS embed.FS + // ComicInfo represents the standard ComicInfo.xml metadata type ComicInfo struct { XMLName xml.Name `xml:"ComicInfo"` @@ -36,8 +47,8 @@ type ComicInfo struct { 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 + Genre string `xml:"Genre"` + TagsXml string `xml:"Tags"` StoryArc string `xml:"StoryArc"` Year string `xml:"Year"` Month string `xml:"Month"` @@ -46,30 +57,32 @@ type ComicInfo struct { } type User struct { - Username string `json:"username"` - PasswordHash string `json:"password_hash"` - IsAdmin bool `json:"is_admin"` // NEW + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + IsAdmin bool `json:"is_admin"` } 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"` + 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:"-"` + Tags []string `json:"tags"` + UploadedAt time.Time `json:"uploaded_at"` + Bookmarks []int `json:"bookmarks"` + LastModified time.Time `json:"last_modified"` } type Session struct { @@ -84,48 +97,61 @@ type Tag struct { } 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 + 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) + coverGenSemaphore = make(chan struct{}, 1) // Only ONE cover generation at a time + 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 + libraryPath = "./library" + cachePath = "./cache/covers" + etcPath = "./etc" + currentUser string + registrationEnabled = true + saveTimer *time.Timer // For debounced saves ) func main() { - // Initialize directories - os.MkdirAll(filepath.Join(libraryPath, "Unorganized"), 0755) + os.MkdirAll(libraryPath, 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/user", authMiddleware(handleUser)) 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/try-passwords/", authMiddleware(handleTryKnownPasswords)) + http.HandleFunc("/api/bookmark/", authMiddleware(handleBookmark)) http.HandleFunc("/api/admin/toggle-registration", authMiddleware(handleToggleRegistration)) http.HandleFunc("/api/admin/delete-comic/", authMiddleware(handleDeleteComic)) http.HandleFunc("/", serveUI) + go func() { + for { + time.Sleep(30 * time.Second) + runtime.GC() + debug.FreeOSMemory() + } + }() + + // Periodic session cleanup + go cleanupSessions() + log.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } @@ -134,179 +160,179 @@ 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 + 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 + Username: req.Username, + PasswordHash: string(hash), + IsAdmin: len(users) == 0, } saveUsers() - if len(users) == 1 { // NEW: Init admin config - saveAdminConfig() - registrationEnabled = true + if len(users) == 1 { + 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}) + 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() + debounceSave() + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"enabled": registrationEnabled}) +} + +func handleUser(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + user := getCurrentUser(r) + json.NewEncoder(w).Encode(map[string]interface{}{ + "username": user.Username, + "is_admin": user.IsAdmin, + }) } 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] + cookie, err := r.Cookie("session") + if err != nil { + return User{} + } + 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") + 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 - } + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } - var req struct { - Username string `json:"username"` - Password string `json:"password"` - } + 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 - } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } - log.Printf("Login attempt: username=%s", req.Username) + user, exists := users[req.Username] + if !exists { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } - 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 { + 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() - 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) - 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() - 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 - 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: "/", + }) - 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, - }) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Login successful", + "token": token, + "is_admin": user.IsAdmin, + }) } func handleLogout(w http.ResponseWriter, r *http.Request) { @@ -316,7 +342,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { delete(sessions, cookie.Value) sessionsMutex.Unlock() } - // Clear sensitive data from memory + comicsMutex.Lock() comics = make(map[string]Comic) comicsMutex.Unlock() @@ -328,7 +354,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { passwordsMutex.Unlock() currentEncryptionKey = nil currentUser = "" - libraryPath = "./library" // Reset to default + libraryPath = "./library" cachePath = "./cache/covers" http.SetCookie(w, &http.Cookie{ @@ -348,15 +374,29 @@ func handleComics(w http.ResponseWriter, r *http.Request) { return } + // Trigger scan if needed (lightweight now) + if len(comics) == 0 { + scanLibrary() + } + comicsMutex.RLock() defer comicsMutex.RUnlock() comicList := make([]Comic, 0, len(comics)) for _, comic := range comics { + // Check if we have a password for this comic + passwordsMutex.RLock() + _, hasPassword := comicPasswords[comic.ID] + passwordsMutex.RUnlock() + + // Update HasPassword flag (lightweight) + if hasPassword { + comic.HasPassword = true + } + 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 @@ -372,89 +412,101 @@ func handleComics(w http.ResponseWriter, r *http.Request) { } 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 { + if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - user := getCurrentUser(r) - if !user.IsAdmin { - http.Error(w, "Admin only", http.StatusForbidden) + + // FIX: Read from the raw Body stream rather than parsing multipart if possible, + // but at minimum, clear the form immediately after use. + reader, err := r.MultipartReader() + if err != nil { + http.Error(w, "Error creating multipart reader", http.StatusBadRequest) 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) + + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + http.Error(w, "Error reading part", http.StatusInternalServerError) + return + } + + if part.FormName() == "file" { + filename := part.FileName() + destPath := filepath.Join(libraryPath, "Unorganized", filename) + destFile, err := os.Create(destPath) + if err != nil { + http.Error(w, "Error saving file", http.StatusInternalServerError) + return + } + + // FIX: Small buffer for the actual write + buf := make([]byte, 32*1024) + _, err = io.CopyBuffer(destFile, part, buf) + destFile.Close() + if err != nil { + http.Error(w, "Error saving file", http.StatusInternalServerError) + return + } + + fileInfo, _ := os.Stat(destPath) + comic := processComic(destPath, filename, fileInfo.ModTime()) + + comicsMutex.Lock() + comics[comic.ID] = comic + comicsMutex.Unlock() + + // FIX: Force GC after the write is finished + buf = nil + runtime.GC() + + json.NewEncoder(w).Encode(comic) + return } - 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 logMemStats(label string) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + log.Printf("[%s] Alloc=%dMB, TotalAlloc=%dMB, Sys=%dMB, NumGC=%d", + label, + m.Alloc/1024/1024, + m.TotalAlloc/1024/1024, + m.Sys/1024/1024, + m.NumGC) +} + +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) + debounceSave() + } + 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) { @@ -476,19 +528,389 @@ func handleCover(w http.ResponseWriter, r *http.Request) { return } - // Check cache first cacheFile := filepath.Join(cachePath, comic.ID+".jpg") + + // Check if cache exists 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) + // Check if we have password for encrypted comics + passwordsMutex.RLock() + password, hasPassword := comicPasswords[comic.ID] + passwordsMutex.RUnlock() + + if comic.Encrypted && !hasPassword { + w.WriteHeader(http.StatusLocked) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "error": "password_required", + "message": "Comic requires password", + }) + return } + + // Set password if we have it + if hasPassword { + comicsMutex.Lock() + c := comics[comic.ID] + c.Password = password + c.HasPassword = true + comics[comic.ID] = c + comic = c + comicsMutex.Unlock() + } + + // Load metadata if not already loaded + if comic.Series == "" && comic.Title == "" { + loadComicMetadataLazy(comic.ID) + comicsMutex.RLock() + comic = comics[comic.ID] + comicsMutex.RUnlock() + } + + log.Printf("Generating cover on-demand for: %s", comic.Filename) + + // NEW: Use a channel-based semaphore for better control + select { + case coverGenSemaphore <- struct{}{}: + // Got the lock + defer func() { <-coverGenSemaphore }() + case <-time.After(30 * time.Second): + // Timeout waiting for cover generation slot + log.Printf("Timeout waiting for cover generation slot") + http.Error(w, "Cover generation busy, try again later", http.StatusServiceUnavailable) + return + } + + // Double-check cache again (another request might have generated it) + if _, err := os.Stat(cacheFile); err == nil { + http.ServeFile(w, r, cacheFile) + return + } + + // Generate with aggressive memory management + err = generateCoverCacheLazy(&comic, cacheFile) + if err != nil { + log.Printf("Failed to generate cover: %v", err) + http.Error(w, "Failed to generate cover", http.StatusInternalServerError) + return + } + + // Serve the newly generated cache + if _, err := os.Stat(cacheFile); err == nil { + http.ServeFile(w, r, cacheFile) + return + } + + http.Error(w, "Cover generation failed", http.StatusInternalServerError) +} + + +func generateCoverCacheLazy(comic *Comic, cacheFile string) error { + // CRITICAL: Set very aggressive GC for this operation + oldGC := debug.SetGCPercent(10) + defer func() { + debug.SetGCPercent(oldGC) + runtime.GC() + debug.FreeOSMemory() + }() + + if comic.FileType != ".cbz" { + return fmt.Errorf("not a CBZ file") + } + + // Check file size first - refuse to process huge files + fi, err := os.Stat(comic.FilePath) + if err != nil { + return err + } + if fi.Size() > 900*1024*1024 { // 900MB max CBZ file + log.Printf("CBZ too large (%d bytes), skipping thumbnail", fi.Size()) + return fmt.Errorf("file too large") + } + + yr, err := yzip.OpenReader(comic.FilePath) + if err != nil { + return err + } + defer yr.Close() + + var imageFiles []*yzip.File + 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 == ".gif" || + ext == ".avif" || ext == ".jxl" || ext == ".webp" || ext == ".bmp" || ext == ".jp2" { + imageFiles = append(imageFiles, f) + } + } + + if len(imageFiles) == 0 { + return fmt.Errorf("no images found") + } + + sort.Slice(imageFiles, func(i, j int) bool { + return imageFiles[i].Name < imageFiles[j].Name + }) + + coverFile := imageFiles[0] + + if coverFile.UncompressedSize64 > 30*1024*1024 { // 30MB uncompressed + log.Printf("Cover image too large (%d bytes), using direct resize", coverFile.UncompressedSize64) + return resizeCoverDirectly(comic, coverFile, cacheFile, 300) + } + + if coverFile.IsEncrypted() { + if comic.Password != "" { + coverFile.SetPassword(comic.Password) + } else { + return fmt.Errorf("encrypted without password") + } + } + + rc, err := coverFile.Open() + if err != nil { + return err + } + defer rc.Close() + + // NEW: First decode config to check dimensions + config, format, err := image.DecodeConfig(rc) + if err == nil { + pixelCount := config.Width * config.Height + log.Printf("Cover dimensions: %dx%d (%d pixels), format: %s", config.Width, config.Height, pixelCount, format) + + // If image is huge (>20 megapixels), use direct resize + if pixelCount > 20*1000*1000 { + rc.Close() + log.Printf("Image too large (%d megapixels), using direct resize", pixelCount/1000000) + return resizeCoverDirectly(comic, coverFile, cacheFile, 300) + } + } + rc.Close() + + // Reopen for actual reading + rc, err = coverFile.Open() + if err != nil { + return err + } + defer rc.Close() + + // Stream to temp file with size limit + tempFile, err := os.CreateTemp(cachePath, "cover-*.tmp") + if err != nil { + return err + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + + // Copy with explicit limit + written, err := io.CopyN(tempFile, rc, 30*1024*1024) // 30MB hard limit + if err != nil && err != io.EOF { + tempFile.Close() + return err + } + tempFile.Close() + + if written == 0 { + return fmt.Errorf("empty image file") + } + + // Force GC before heavy operation + runtime.GC() + + // Resize with aggressive memory management + return resizeImageAggressively(tempPath, cacheFile, 300) // Reduced from 400 +} + + +// New function: resize directly from reader for huge images +// NEW: Improved resizeCoverDirectly with streaming decode +func resizeCoverDirectly(comic *Comic, coverFile *yzip.File, cacheFile string, maxDim int) error { + if coverFile.IsEncrypted() && comic.Password != "" { + coverFile.SetPassword(comic.Password) + } + + // 1. Extract and decrypt to disk first + tmp, err := os.CreateTemp("", "cover-extract-*.img") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + defer tmp.Close() + + rc, err := coverFile.Open() + if err != nil { + return err + } + _, err = io.Copy(tmp, rc) + rc.Close() + if err != nil { + return err + } + + // 2. Decode from Disk-based reader + tmp.Seek(0, 0) + img, _, err := image.Decode(tmp) + if err != nil { + return err + } + tmp.Close() // Close early + + // 3. Resize logic + bounds := img.Bounds() + width, height := bounds.Dx(), bounds.Dy() + var newWidth, newHeight int + if width > height { + newWidth = maxDim + newHeight = int(float64(height) * float64(maxDim) / float64(width)) + } else { + newHeight = maxDim + newWidth = int(float64(width) * float64(maxDim) / float64(height)) + } + + // Use Bilinear for better speed/memory balance on huge 33MP images + resized := resize.Resize(uint(newWidth), uint(newHeight), img, resize.Bilinear) + + // CRITICAL: Nil the 132MB bitmap immediately + img = nil + runtime.GC() + + out, err := os.Create(cacheFile) + if err != nil { + return err + } + defer out.Close() + + err = jpeg.Encode(out, resized, &jpeg.Options{Quality: 75}) + resized = nil + runtime.GC() + + return err +} + + +func resizeImageAggressively(inputPath, outputPath string, maxDimension int) error { + f, err := os.Open(inputPath) + if err != nil { + return err + } + defer f.Close() + + // First check dimensions WITHOUT decoding full image + config, format, err := image.DecodeConfig(f) + if err != nil { + return err + } + + log.Printf("Resizing %s image: %dx%d", format, config.Width, config.Height) + + // Seek back to start + f.Seek(0, 0) + + // Decode with size awareness + img, _, err := image.Decode(f) + if err != nil { + return err + } + f.Close() + + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + // Calculate target dimensions + var newWidth, newHeight int + if width > height { + newWidth = maxDimension + newHeight = int(float64(height) * float64(maxDimension) / float64(width)) + } else { + newHeight = maxDimension + newWidth = int(float64(width) * float64(maxDimension) / float64(height)) + } + + if newWidth < 1 { + newWidth = 1 + } + if newHeight < 1 { + newHeight = 1 + } + + // Choose resize method based on size ratio + var resizeMethod resize.InterpolationFunction + ratio := float64(width*height) / float64(newWidth*newHeight) + + if ratio > 100 { // Massive reduction (>100x pixels) + resizeMethod = resize.NearestNeighbor + log.Printf("Using NearestNeighbor (ratio: %.1f)", ratio) + } else if ratio > 25 { + resizeMethod = resize.Bilinear + log.Printf("Using Bilinear (ratio: %.1f)", ratio) + } else { + resizeMethod = resize.Lanczos3 + log.Printf("Using Lanczos3 (ratio: %.1f)", ratio) + } + + // For VERY large images, do multi-pass resize + if ratio > 50 { + // First pass: reduce to intermediate size + intermediateSize := maxDimension * 3 + var iWidth, iHeight int + if width > height { + iWidth = intermediateSize + iHeight = int(float64(height) * float64(intermediateSize) / float64(width)) + } else { + iHeight = intermediateSize + iWidth = int(float64(width) * float64(intermediateSize) / float64(height)) + } + + log.Printf("Multi-pass resize: %dx%d -> %dx%d -> %dx%d", + width, height, iWidth, iHeight, newWidth, newHeight) + + // First pass + tempImg := resize.Resize(uint(iWidth), uint(iHeight), img, resize.NearestNeighbor) + img = nil + runtime.GC() + + // Second pass + resized := resize.Resize(uint(newWidth), uint(newHeight), tempImg, resize.Lanczos3) + tempImg = nil + runtime.GC() + + // Save + return saveJPEG(resized, outputPath) + } + + // Single pass for smaller reductions + resized := resize.Resize(uint(newWidth), uint(newHeight), img, resizeMethod) + img = nil + runtime.GC() + + return saveJPEG(resized, outputPath) +} + +// Helper function to save JPEG and free memory +func saveJPEG(img image.Image, path string) error { + out, err := os.Create(path) + if err != nil { + img = nil + return err + } + defer out.Close() + + // Lower quality = smaller memory footprint during encoding + err = jpeg.Encode(out, img, &jpeg.Options{Quality: 70}) + img = nil + + runtime.GC() + debug.FreeOSMemory() + + return err } func handleTags(w http.ResponseWriter, r *http.Request) { @@ -537,7 +959,7 @@ func handleTags(w http.ResponseWriter, r *http.Request) { } tagsMutex.Unlock() - saveTags() + debounceSave() json.NewEncoder(w).Encode(tags[req.Name]) default: @@ -581,7 +1003,6 @@ func handleComicTags(w http.ResponseWriter, r *http.Request) { return } - // Add tag if not already present found := false for _, t := range comic.Tags { if t == req.Tag { @@ -594,7 +1015,7 @@ func handleComicTags(w http.ResponseWriter, r *http.Request) { comic.Tags = append(comic.Tags, req.Tag) comics[decodedID] = comic updateTagCount(req.Tag, 1) - saveComics() + debounceSave() } w.Header().Set("Content-Type", "application/json") @@ -622,7 +1043,7 @@ func handleComicTags(w http.ResponseWriter, r *http.Request) { comic.Tags = newTags comics[decodedID] = comic updateTagCount(tagToRemove, -1) - saveComics() + debounceSave() } w.Header().Set("Content-Type", "application/json") @@ -633,6 +1054,131 @@ func handleComicTags(w http.ResponseWriter, r *http.Request) { } } +func handleTryKnownPasswords(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/try-passwords/") + decodedID, err := url.QueryUnescape(id) + if err != nil { + decodedID = id + } + + comicsMutex.RLock() + comic, exists := comics[decodedID] + comicsMutex.RUnlock() + + if !exists { + http.Error(w, "Comic not found", http.StatusNotFound) + return + } + + if !comic.Encrypted { + http.Error(w, "Comic not encrypted", http.StatusBadRequest) + return + } + + // Get all known passwords + passwordsMutex.RLock() + knownPasswords := make([]string, 0, len(comicPasswords)) + for _, pwd := range comicPasswords { + found := false + for _, existing := range knownPasswords { + if existing == pwd { + found = true + break + } + } + if !found { + knownPasswords = append(knownPasswords, pwd) + } + } + passwordsMutex.RUnlock() + + if len(knownPasswords) == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "No known passwords to try", + }) + return + } + + // Try each password using the new validation function + validPassword := "" + for _, pwd := range knownPasswords { + if validatePassword(comic.FilePath, pwd) { + validPassword = pwd + break + } + } + + if validPassword == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "None of the known passwords worked", + }) + return + } + + // Password worked! Save it and extract metadata + comicsMutex.Lock() + c := comics[decodedID] + c.Password = validPassword + c.HasPassword = true + extractCBZMetadata(&c) + + 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() + + 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 + } + } + } + + comics[decodedID] = c + comicsMutex.Unlock() + + passwordsMutex.Lock() + comicPasswords[decodedID] = validPassword + passwordsMutex.Unlock() + + debounceSave() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Password found and applied", + "comic": c, + }) +} + func handleSetPassword(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -670,42 +1216,13 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) { 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 { + // Use the new validation function + if !validatePassword(comic.FilePath, req.Password) { http.Error(w, "Invalid password", http.StatusBadRequest) return } - // Set and save + // Password is valid - save it comicsMutex.Lock() c := comics[decodedID] c.Password = req.Password @@ -716,54 +1233,226 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) { passwordsMutex.Lock() comicPasswords[decodedID] = req.Password passwordsMutex.Unlock() - savePasswords() - // Extract metadata now that password is known + // NOW extract metadata with the valid password 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() + // Update 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() - saveComics() - saveTags() + // Auto-organize if we have 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 + } + } + } + + comics[decodedID] = c + comicsMutex.Unlock() + + debounceSave() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Password set successfully"}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Password set successfully", + "comic": c, + }) +} + +func validatePassword(filePath string, password string) bool { + yr, err := yzip.OpenReader(filePath) + if err != nil { + return false + } + defer yr.Close() + + // Try ComicInfo.xml first if it exists + for _, f := range yr.File { + if strings.ToLower(f.Name) == "comicinfo.xml" { + if !f.IsEncrypted() { + return true // Not encrypted + } + + f.SetPassword(password) + rc, err := f.Open() + if err != nil { + return false + } + + // Try to read a small amount + buf := make([]byte, 100) + n, err := rc.Read(buf) + rc.Close() + + if err != nil && err != io.EOF { + return false + } + + // If we read something and it looks like XML, password is valid + if n > 0 && strings.Contains(string(buf[:n]), "= 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) { @@ -1066,6 +1584,30 @@ func handleComicPages(w http.ResponseWriter, r *http.Request) { return } + // Check if we have password + passwordsMutex.RLock() + password, hasPassword := comicPasswords[comic.ID] + passwordsMutex.RUnlock() + + if comic.Encrypted && !hasPassword { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "needs_password": true, + "page_count": 0, + "pages": []string{}, + }) + return + } + + // Load metadata on first access (if not already loaded) + if comic.Series == "" && comic.Title == "" { + loadComicMetadataLazy(comic.ID) + // Re-fetch comic after metadata load + comicsMutex.RLock() + comic = comics[comic.ID] + comicsMutex.RUnlock() + } + if comic.FileType != ".cbz" { json.NewEncoder(w).Encode(map[string]interface{}{ "page_count": 0, @@ -1082,7 +1624,6 @@ func handleComicPages(w http.ResponseWriter, r *http.Request) { defer yr.Close() var imageFiles []string - needsPassword := comic.Encrypted && comic.Password == "" && !comic.HasPassword for _, f := range yr.File { if f.FileInfo().IsDir() { continue @@ -1090,331 +1631,228 @@ func handleComicPages(w http.ResponseWriter, r *http.Request) { 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 + + // Set password if encrypted + if f.IsEncrypted() && hasPassword { + f.SetPassword(password) } + imageFiles = append(imageFiles, f.Name) } } sort.Strings(imageFiles) - data := map[string]interface{}{ + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(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 - } +func processComic(filePath, filename string, modTime time.Time) Comic { + comic := Comic{ + ID: generateToken(), + Filename: filename, + FilePath: filePath, + FileType: strings.ToLower(filepath.Ext(filename)), + UploadedAt: time.Now(), + Artist: "Unknown", + Tags: []string{}, + Bookmarks: []int{}, + LastModified: modTime, + Encrypted: false, + HasPassword: false, + } - var req struct { - ComicID string `json:"comic_id"` - Inker string `json:"inker"` - StoryArc string `json:"story_arc"` - } + // Quick check if encrypted (ONLY check, don't decrypt) + if comic.FileType == ".cbz" { + yr, err := yzip.OpenReader(comic.FilePath) + if err == nil { + // Just check first file for encryption + for _, f := range yr.File { + if f.IsEncrypted() { + comic.Encrypted = true + break + } + } + yr.Close() + } + } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } + // Extract artist from directory structure only + parentDir := filepath.Dir(filePath) + if filepath.Base(parentDir) != "Unorganized" { + dirName := filepath.Base(filepath.Dir(parentDir)) + comic.Artist = dirName + } + comic.CoverImage = "/api/cover/" + url.QueryEscape(comic.ID) + + // DO NOT: extract metadata + // DO NOT: generate covers + // DO NOT: try passwords + + return comic +} + +func loadComicMetadataLazy(comicID string) error { comicsMutex.Lock() defer comicsMutex.Unlock() - comic, exists := comics[req.ComicID] + comic, exists := comics[comicID] if !exists { - http.Error(w, "Comic not found", http.StatusNotFound) - return + return fmt.Errorf("comic not found") } - inker := sanitizeFilename(req.Inker) - storyArc := sanitizeFilename(req.StoryArc) - if inker == "" { - inker = "Unknown" - } - if storyArc == "" { - storyArc = "No_StoryArc" + // Already has metadata, skip + if comic.Series != "" || comic.Title != "" { + return nil } - newDir := filepath.Join(libraryPath, inker, storyArc) - os.MkdirAll(newDir, 0755) + // Check if we have a password + passwordsMutex.RLock() + password, hasPassword := comicPasswords[comic.ID] + passwordsMutex.RUnlock() - 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 + if comic.Encrypted && !hasPassword { + return fmt.Errorf("password required") } - 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{}, + // Set password if we have it + if hasPassword { + comic.Password = password + comic.HasPassword = true } - 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] + // Extract metadata NOW + extractCBZMetadata(&comic) + + // Update tags + tagsMutex.Lock() + for _, tag := range comic.Tags { + if tagData, exists := tags[tag]; exists { tagData.Count++ tags[tag] = tagData + } else { + tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1} } - tagsMutex.Unlock() - saveTags() + } + tagsMutex.Unlock() - // 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 - } + // Auto-organize if we have metadata + 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) + filename := filepath.Base(comic.FilePath) + newPath := filepath.Join(newDir, filename) + if newPath != comic.FilePath { + if err := os.Rename(comic.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 - } + comics[comicID] = comic + debounceSave() - return comic + return nil } -func generateCoverCache(comic *Comic) { +func extractCBZMetadata(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() { + if strings.ToLower(f.Name) != "comicinfo.xml" { 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 f.IsEncrypted() && comic.Password != "" { + f.SetPassword(comic.Password) } - } - 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) + // Create temp file for the XML to offload RAM + tmp, err := os.CreateTemp("", "comic-metadata-*.xml") + if err != nil { return } - } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + defer tmp.Close() - 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 + rc, err := f.Open() + if err != nil { + return } - } - 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 + // Decrypt stream directly to disk (32KB buffer usage) + _, err = io.Copy(tmp, rc) + rc.Close() + if err != nil { + return } - } - passwordsMutex.RUnlock() - foundPwd := "" - for _, f := range yr.File { - if strings.ToLower(f.Name) == "comicinfo.xml" { - var data []byte - var readErr error + tmp.Seek(0, 0) + var info ComicInfo + if err := xml.NewDecoder(tmp).Decode(&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 - 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 info.Artist != "" { + comic.Artist = info.Artist + } else if info.Writer != "" { + comic.Artist = info.Writer + } + + // --- FIX: TAG EXTRACTION LOGIC --- + tagsSource := info.TagsXml + if tagsSource == "" { + tagsSource = info.Genre + } + + if tagsSource != "" { + // Split by common delimiters: comma, semicolon, or pipe + rawTags := strings.FieldsFunc(tagsSource, func(r rune) bool { + return r == ',' || r == ';' || r == '|' + }) + + comic.Tags = make([]string, 0, len(rawTags)) + for _, tag := range rawTags { + trimmed := strings.TrimSpace(tag) + if trimmed != "" { + comic.Tags = append(comic.Tags, trimmed) } } } - - 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 + // --------------------------------- } + // Break only after we've processed everything in the XML + 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 { @@ -1434,48 +1872,39 @@ func scanLibrary() { comicsMutex.RLock() id, exists := existingPaths[path] + var currentMTime time.Time + if exists { + currentMTime = comics[id].LastModified + } 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() - } + // Skip if unchanged + if exists && currentMTime.Equal(info.ModTime()) { 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() + // Modified or new file - just update the record + if exists { + comicsMutex.Lock() + c := comics[id] + c.LastModified = info.ModTime() + comics[id] = c + comicsMutex.Unlock() + } else { + // New file - create lightweight entry + comic := processComic(path, info.Name(), info.ModTime()) + comicsMutex.Lock() + comics[comic.ID] = comic + comicsMutex.Unlock() + } return nil }) - // Clean up comics that no longer exist + // Remove deleted files 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) } @@ -1484,8 +1913,9 @@ func scanLibrary() { } comicsMutex.Unlock() - saveComics() - saveTags() + debounceSave() + runtime.GC() + debug.FreeOSMemory() } func authMiddleware(next http.HandlerFunc) http.HandlerFunc { @@ -1509,27 +1939,22 @@ func authMiddleware(next http.HandlerFunc) http.HandlerFunc { } } -// 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) - } + data, err := os.ReadFile("etc/users.json") + if err != nil { + 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) - } - } + 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 + } + } } func saveUsers() { @@ -1537,11 +1962,10 @@ func saveUsers() { 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) + config := struct{ RegistrationEnabled bool }{RegistrationEnabled: registrationEnabled} + data, _ := json.MarshalIndent(config, "", " ") + os.WriteFile("etc/admin.json", data, 0644) } func loadTags() { @@ -1555,11 +1979,15 @@ func loadTags() { } func saveTags() { + tagsMutex.RLock() + defer tagsMutex.RUnlock() data, _ := json.MarshalIndent(tags, "", " ") os.WriteFile(filepath.Join(libraryPath, "tags.json"), data, 0644) } func saveComics() { + comicsMutex.RLock() + defer comicsMutex.RUnlock() data, _ := json.MarshalIndent(comics, "", " ") os.WriteFile(filepath.Join(libraryPath, "comics.json"), data, 0644) } @@ -1577,45 +2005,30 @@ func loadComics() { 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 } @@ -1623,20 +2036,28 @@ func savePasswords() { 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) + os.WriteFile(filepath.Join(libraryPath, "passwords.json"), []byte(b64), 0644) +} + +// Debounced save for comics and tags +func debounceSave() { + if saveTimer != nil { + saveTimer.Stop() } + saveTimer = time.AfterFunc(5*time.Second, func() { + saveComics() + saveTags() + savePasswords() // Also save passwords if needed + }) } func updateTagCount(tagName string, delta int) { @@ -1649,7 +2070,6 @@ func updateTagCount(tagName string, delta int) { tag.Count = 0 } tags[tagName] = tag - saveTags() } } @@ -1659,12 +2079,9 @@ func generateToken() string { } 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" @@ -1698,31 +2115,6 @@ func deriveKey(seed string) []byte { 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 { @@ -1734,12 +2126,16 @@ func decryptAES(data []byte, key []byte) ([]byte, error) { } iv := data[:aes.BlockSize] - data = data[aes.BlockSize:] + ciphertext := data[aes.BlockSize:] stream := cipher.NewCFBDecrypter(block, iv) - stream.XORKeyStream(data, data) - return data, nil + // FIX: Allocate a separate slice for plaintext so the + // original 'data' slice can be garbage collected. + plaintext := make([]byte, len(ciphertext)) + stream.XORKeyStream(plaintext, ciphertext) + + return plaintext, nil } func encryptAES(plaintext []byte, key []byte) ([]byte, error) { @@ -1748,1581 +2144,39 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) { 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) { + data, err := templateFS.ReadFile("templates/index.html") + if err != nil { + http.Error(w, "Template not found", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "text/html") - w.Write([]byte(getHTML())) + w.Write(data) } -func getHTML() string { - return ` - - - - - Gopherbook - - - -
-
-

Gopherbook

- -
-
- -
-
- -
- - -
- -
-
- - -
-
- - -
- -
- - -
- - -
- -
-
-
Loading...
-
- - - / 0 - - - -
-
-
- Comic page -
- - - -
-
-
- -
- -
- -
-
-
-
🔒 Password Required
-
This comic is encrypted
-
- - - -
- - -
- -
- - -
-
-
- - - - - -` } diff --git a/app/gopherbook/templates/index.html b/app/gopherbook/templates/index.html new file mode 100644 index 0000000..83e923c --- /dev/null +++ b/app/gopherbook/templates/index.html @@ -0,0 +1,1991 @@ + + + + + + Gopherbook + + + +
+
+

Gopherbook

+ +
+
+ +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+ +
+ + +
+ + +
+ +
+
+
Loading...
+
+ + + / 0 + + + + + + +
+
+
+ +
+ Comic page +
+
+ + + +
+
+
+ +
+ +
+ +
+
+
+
🔒 Password Required
+
This comic is encrypted
+
+ + + +
+ + +
+ +
+ + +
+
+
+ + + + + + diff --git a/bash-scripts/run.sh b/bash-scripts/run.sh index 68ebbdf..ee514eb 100755 --- a/bash-scripts/run.sh +++ b/bash-scripts/run.sh @@ -21,8 +21,15 @@ if podman container exists "$CONTAINER_NAME"; then 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" +# IMPROVED: Better memory settings and limits +podman run -d --name "$CONTAINER_NAME" \ + --memory=512m \ + --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." @@ -33,3 +40,6 @@ echo "Cleaning up old images..." podman image prune --force echo "Update and cleanup complete!" +echo "Container is running with memory limit: 512MB, swap: 512MB" +echo "Go memory limit (GOMEMLIMIT): 512MiB" +echo "Aggressive GC enabled (GOGC=50)" diff --git a/go.mod b/go.mod index 44a741e..29e9c8b 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,10 @@ require ( github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 golang.org/x/crypto v0.43.0 ) + +require ( + github.com/ebitengine/purego v0.8.3 // indirect + github.com/gen2brain/avif v0.4.4 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect +) diff --git a/go.sum b/go.sum index 47120ee..983e747 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= +github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gen2brain/avif v0.4.4 h1:Ga/ss7qcWWQm2bxFpnjYjhJsNfZrWs5RsyklgFjKRSE= +github.com/gen2brain/avif v0.4.4/go.mod h1:/XCaJcjZraQwKVhpu9aEd9aLOssYOawLvhMBtmHVGqk= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= 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=