diff --git a/.containerignore b/.containerignore index b06848b..3a14218 100644 --- a/.containerignore +++ b/.containerignore @@ -9,4 +9,3 @@ xml-template cache etc library -watch diff --git a/.gitignore b/.gitignore index e206118..0d59517 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ cache library etc -watch diff --git a/Makefile b/Makefile deleted file mode 100644 index 1cd8552..0000000 --- a/Makefile +++ /dev/null @@ -1,5 +0,0 @@ -build: - go build -o bin/main app/gopherbook/main.go - -clean: - rm -rf watch etc library cache diff --git a/README.md b/README.md index b333156..d9c4a95 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Gopherbook – Self-Hosted Comic Library & CBZ/CBT Reader +# 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/CBT files), including support for password-protected/encrypted archives, per-user libraries, tagging, automatic organization, and a clean modern reader. +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 @@ -9,11 +9,10 @@ It is designed for people who want full control over their digital comic collect ## Features -- Upload & read `.cbz` (ZIP-based) or `.cbt` (TAR-based) comics directly in the browser -- **Watch folder support for bulk imports** – drop CBZ/CBT files into your watch folder and they're automatically imported +- 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) or CBT files (AES-256-CFB Openssl) +- 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.) @@ -43,6 +42,7 @@ It is designed for people who want full control over their digital comic collect - 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 @@ -53,10 +53,11 @@ go build -o gopherbook app/gopherbook/main.go 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 -./scripts-bash/run.sh +./bash-scripts/run.sh ``` Then open http://localhost:12010 in your browser. @@ -64,56 +65,23 @@ 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/CBT files +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 -./watch/username/ ← watch folder for bulk imports (auto-scanned) ./etc/users.json ← user accounts (bcrypt hashes) ./etc/admin.json ← admin settings (registration toggle) ``` -## Watch folder for bulk imports - -Gopherbook includes an automatic watch folder system that makes bulk importing comics effortless: - -- **Per-user watch folders**: Each user gets their own watch folder at `./watch/[username]/` -- **Automatic scanning**: The system checks for new CBZ/CBT files every 10 seconds -- **Smart debouncing**: Waits 5 seconds after detecting files to ensure they're fully copied -- **File validation**: Checks that files aren't still being written before importing -- **Duplicate handling**: Automatically renames files if they already exist (adds _1, _2, etc.) -- **Zero configuration**: Just drop CBZ/CBT files into your watch folder and they appear in your library - -### How to use - -1. After logging in, your personal watch folder is at `./watch/[yourusername]/` -2. Copy or move CBZ/CBT files into this folder using any method: - - Direct file copy/paste - - SCP/SFTP upload - - Network share mount - - Automated scripts -3. Within ~15 seconds, files are automatically imported to your library -4. Files are **moved** (not copied) to preserve disk space -5. Check the API endpoint `/api/watch-folder` to see pending files - -**Example workflow:** -```bash -# Bulk copy comics to your watch folder -cp ~/Downloads/*.cbz ./watch/myusername/ -cp ~/Downloads/*.cbt ./watch/myusername/ - -# Wait ~15 seconds, then check your library in the web UI -# All comics will be imported and organized automatically -``` - ## How encrypted/password-protected comics work -- When you upload or scan an encrypted CBZ/CBT that has no known password yet, the server marks it as Encrypted = true. +- 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) @@ -184,7 +152,7 @@ Please open an issue first for bigger changes. - Everyone who hoards comics ❤️ Enjoy your library! -– Happy reading with Gopherbook +— Happy reading with Gopherbook
@@ -192,7 +160,6 @@ Enjoy your library! ![Arch](https://img.shields.io/badge/Arch%20Linux-1793D1?logo=arch-linux&logoColor=fff&style=for-the-badge) ![Gimp Gnu Image Manipulation Program](https://img.shields.io/badge/Gimp-657D8B?style=for-the-badge&logo=gimp&logoColor=FFFFFF) -![Podman](https://img.shields.io/badge/-Podman-892CA0?style=flat-square&logo=podman&logoColor=white) ![Vim](https://img.shields.io/badge/VIM-%2311AB00.svg?style=for-the-badge&logo=vim&logoColor=white) ![Git](https://img.shields.io/badge/git-%23F05033.svg?style=for-the-badge&logo=git&logoColor=white) ![Forgejo](https://img.shields.io/badge/forgejo-%23FB923C.svg?style=for-the-badge&logo=forgejo&logoColor=white) diff --git a/app/gopherbook/main.go b/app/gopherbook/main.go index 87bab00..b9ab47b 100644 --- a/app/gopherbook/main.go +++ b/app/gopherbook/main.go @@ -1,8 +1,6 @@ package main import ( - "archive/tar" - "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -98,12 +96,6 @@ type Tag struct { Count int `json:"count"` } -type TarFileInfo struct { - Name string - Size int64 - Data []byte -} - var ( users = make(map[string]User) sessions = make(map[string]Session) @@ -122,19 +114,14 @@ var ( currentUser string registrationEnabled = true saveTimer *time.Timer // For debounced saves - watchFolders = make(map[string]*time.Timer) - watchMutex sync.RWMutex - watchPath = "./watch" ) func main() { os.MkdirAll(libraryPath, 0755) os.MkdirAll(cachePath, 0755) os.MkdirAll(etcPath, 0755) - os.MkdirAll(watchPath, 0755) loadUsers() - initWatchFolders() http.HandleFunc("/api/register", handleRegister) http.HandleFunc("/api/login", handleLogin) @@ -152,7 +139,6 @@ func main() { http.HandleFunc("/api/bookmark/", authMiddleware(handleBookmark)) http.HandleFunc("/api/admin/toggle-registration", authMiddleware(handleToggleRegistration)) http.HandleFunc("/api/admin/delete-comic/", authMiddleware(handleDeleteComic)) - http.HandleFunc("/api/watch-folder", authMiddleware(handleWatchFolder)) http.HandleFunc("/", serveUI) go func() { @@ -223,9 +209,6 @@ func handleRegister(w http.ResponseWriter, r *http.Request) { os.MkdirAll(filepath.Join(userLibrary, "Unorganized"), 0755) os.MkdirAll(filepath.Join("./cache/covers", req.Username), 0755) - userWatchPath := filepath.Join(watchPath, req.Username) - os.MkdirAll(userWatchPath, 0755) - w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"message": "User created"}) } @@ -278,233 +261,6 @@ func getCurrentUser(r *http.Request) User { return users[session.Username] } -func handleWatchFolder(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) - userWatchPath := filepath.Join(watchPath, user.Username) - - // Get list of files currently in watch folder - files, err := os.ReadDir(userWatchPath) - if err != nil { - http.Error(w, "Error reading watch folder", http.StatusInternalServerError) - return - } - - var cbzFiles []string - for _, file := range files { - if file.IsDir() { - continue - } - ext := strings.ToLower(filepath.Ext(file.Name())) - if ext == ".cbz" { - cbzFiles = append(cbzFiles, file.Name()) - } - } - - json.NewEncoder(w).Encode(map[string]interface{}{ - "watch_path": userWatchPath, - "files": cbzFiles, - "count": len(cbzFiles), - }) -} - -func moveFile(src, dst string) error { - // Try rename first (fast if same filesystem) - err := os.Rename(src, dst) - if err == nil { - return nil - } - - // If rename fails (cross-filesystem), copy and delete - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() - - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - // Copy the file contents - _, err = io.Copy(destFile, sourceFile) - if err != nil { - return err - } - - // Ensure data is written to disk - err = destFile.Sync() - if err != nil { - return err - } - - // Close files before removing source - sourceFile.Close() - destFile.Close() - - // Remove the source file - return os.Remove(src) -} - -func importWatchFolderFiles(username, watchDir string) { - log.Printf("Processing watch folder for user: %s", username) - - files, err := os.ReadDir(watchDir) - if err != nil { - log.Printf("Error reading watch folder: %v", err) - return - } - - imported := 0 - failed := 0 - - for _, file := range files { - if file.IsDir() { - continue - } - - ext := strings.ToLower(filepath.Ext(file.Name())) - // Support both CBZ and CBT - if ext != ".cbz" && ext != ".cbt" { - continue - } - - sourcePath := filepath.Join(watchDir, file.Name()) - - // Check if file is still being written - info1, err := os.Stat(sourcePath) - if err != nil { - continue - } - time.Sleep(500 * time.Millisecond) - info2, err := os.Stat(sourcePath) - if err != nil { - continue - } - - if info1.Size() != info2.Size() { - log.Printf("File still being written: %s", file.Name()) - continue - } - - // Import the file - destPath := filepath.Join(libraryPath, "Unorganized", file.Name()) - - // Handle duplicate filenames - counter := 1 - originalName := strings.TrimSuffix(file.Name(), ext) - for { - if _, err := os.Stat(destPath); os.IsNotExist(err) { - break - } - destPath = filepath.Join(libraryPath, "Unorganized", - fmt.Sprintf("%s_%d%s", originalName, counter, ext)) - counter++ - } - - // Move the file - err = moveFile(sourcePath, destPath) - if err != nil { - log.Printf("Error moving file %s: %v", file.Name(), err) - failed++ - continue - } - - // Process the comic - fileInfo, _ := os.Stat(destPath) - comic := processComic(destPath, filepath.Base(destPath), fileInfo.ModTime()) - - comicsMutex.Lock() - comics[comic.ID] = comic - comicsMutex.Unlock() - - imported++ - log.Printf("Imported: %s -> %s", file.Name(), comic.ID) - } - - if imported > 0 || failed > 0 { - log.Printf("Watch folder import complete: %d imported, %d failed", imported, failed) - debounceSave() - runtime.GC() - } -} - -func processWatchFolder(username, watchDir string) { - // Check if this is the current logged-in user - if currentUser != username { - return - } - - files, err := os.ReadDir(watchDir) - if err != nil { - return - } - - hasFiles := false - for _, file := range files { - if file.IsDir() { - continue - } - - ext := strings.ToLower(filepath.Ext(file.Name())) - if ext != ".cbz" && ext != ".cbt" { - continue - } - - hasFiles = true - break - } - - if !hasFiles { - return - } - - // Debounce: wait for all files to finish copying - watchMutex.Lock() - if timer, exists := watchFolders[username]; exists { - timer.Stop() - } - - watchFolders[username] = time.AfterFunc(5*time.Second, func() { - importWatchFolderFiles(username, watchDir) - }) - watchMutex.Unlock() -} - -func startWatchingUser(username string) { - userWatchPath := filepath.Join(watchPath, username) - os.MkdirAll(userWatchPath, 0755) - - go func() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - log.Printf("Started watching folder for user: %s", username) - - for range ticker.C { - processWatchFolder(username, userWatchPath) - } - }() -} - -func initWatchFolders() { - os.MkdirAll(watchPath, 0755) - - // Create watch folders for existing users - for username := range users { - userWatchPath := filepath.Join(watchPath, username) - os.MkdirAll(userWatchPath, 0755) - } -} - func handleLogin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -563,7 +319,6 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { loadTags() loadPasswordsWithKey(key) currentEncryptionKey = key - startWatchingUser(req.Username) http.SetCookie(w, &http.Cookie{ Name: "session", @@ -657,66 +412,62 @@ 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 - } + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } - reader, err := r.MultipartReader() - if err != nil { - http.Error(w, "Error creating multipart reader", http.StatusBadRequest) - return - } + // 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 + } - for { - part, err := reader.NextPart() - if err == io.EOF { - break - } - if err != nil { - http.Error(w, "Error reading part", http.StatusInternalServerError) - return - } + 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() + 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 + } - // Validate file extension (CBZ or CBT) - ext := strings.ToLower(filepath.Ext(filename)) - if ext != ".cbz" && ext != ".cbt" { - http.Error(w, "Only .cbz and .cbt files are supported", http.StatusBadRequest) - 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 + } - destPath := filepath.Join(libraryPath, "Unorganized", filename) - destFile, err := os.Create(destPath) - if err != nil { - http.Error(w, "Error saving file", http.StatusInternalServerError) - return - } + fileInfo, _ := os.Stat(destPath) + comic := processComic(destPath, filename, fileInfo.ModTime()) - 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 - } + comicsMutex.Lock() + comics[comic.ID] = comic + comicsMutex.Unlock() - fileInfo, _ := os.Stat(destPath) - comic := processComic(destPath, filename, fileInfo.ModTime()) + // FIX: Force GC after the write is finished + buf = nil + runtime.GC() - comicsMutex.Lock() - comics[comic.ID] = comic - comicsMutex.Unlock() - - buf = nil - runtime.GC() - - json.NewEncoder(w).Encode(comic) - return - } - } + json.NewEncoder(w).Encode(comic) + return + } + } } func logMemStats(label string) { @@ -821,34 +572,28 @@ func handleCover(w http.ResponseWriter, r *http.Request) { log.Printf("Generating cover on-demand for: %s", comic.Filename) - // Use semaphore for cover generation + // 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 + // Double-check cache again (another request might have generated it) if _, err := os.Stat(cacheFile); err == nil { http.ServeFile(w, r, cacheFile) return } - // Generate based on file type - var genErr error - if comic.FileType == ".cbt" { - genErr = generateCBTCover(&comic, cacheFile) - } else if comic.FileType == ".cbz" { - genErr = generateCoverCacheLazy(&comic, cacheFile) - } else { - genErr = fmt.Errorf("unsupported file type: %s", comic.FileType) - } - - if genErr != nil { - log.Printf("Failed to generate cover: %v", genErr) + // 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 } @@ -863,446 +608,6 @@ func handleCover(w http.ResponseWriter, r *http.Request) { } -func isTarEncrypted(filePath string) (bool, error) { - f, err := os.Open(filePath) - if err != nil { - return false, err - } - defer f.Close() - - // Read first 512 bytes (tar header size) - header := make([]byte, 512) - n, err := f.Read(header) - if err != nil && err != io.EOF { - return false, err - } - - // If we can successfully parse a tar header, it's not encrypted - reader := tar.NewReader(bytes.NewReader(header[:n])) - _, err = reader.Next() - - // If Next() succeeds, tar is valid (not encrypted) - // If it fails, likely encrypted - return err != nil, nil -} - -func openEncryptedTar(filePath, password string) (*tar.Reader, error) { - f, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer f.Close() - - // Read entire file (we need it all for decryption) - data, err := io.ReadAll(f) - if err != nil { - return nil, err - } - - // Decrypt - key := deriveKey(password) - decrypted, err := decryptAES(data, key) - if err != nil { - return nil, err - } - - // Create tar reader from decrypted data - return tar.NewReader(bytes.NewReader(decrypted)), nil -} - -func extractCBTMetadata(comic *Comic) { - if comic.FileType != ".cbt" { - return - } - - var tr *tar.Reader - var err error - - // Check if encrypted - encrypted, _ := isTarEncrypted(comic.FilePath) - - if encrypted { - if comic.Password == "" { - return // Can't decrypt without password - } - tr, err = openEncryptedTar(comic.FilePath, comic.Password) - } else { - f, err := os.Open(comic.FilePath) - if err != nil { - return - } - defer f.Close() - tr = tar.NewReader(f) - } - - if err != nil { - return - } - - // Look for ComicInfo.xml - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return - } - - if strings.ToLower(header.Name) == "comicinfo.xml" || - strings.HasSuffix(strings.ToLower(header.Name), "/comicinfo.xml") { - - // Read the XML data - xmlData, err := io.ReadAll(tr) - if err != nil { - return - } - - var info ComicInfo - if err := xml.Unmarshal(xmlData, &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 info.Artist != "" { - comic.Artist = info.Artist - } else if info.Writer != "" { - comic.Artist = info.Writer - } - - // Extract tags - tagsSource := info.TagsXml - if tagsSource == "" { - tagsSource = info.Genre - } - - if tagsSource != "" { - 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) - } - } - } - } - break - } - } -} - -// Add this function to open a regular (unencrypted) tar file -func openTar(filePath string) (*tar.Reader, error) { - f, err := os.Open(filePath) - if err != nil { - return nil, err - } - // Note: caller must close the underlying file - return tar.NewReader(f), nil -} - -func readTarFiles(tr *tar.Reader) ([]TarFileInfo, error) { - var files []TarFileInfo - - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - - // Skip directories - if header.Typeflag == tar.TypeDir { - continue - } - - // Read file data - data, err := io.ReadAll(tr) - if err != nil { - return nil, err - } - - files = append(files, TarFileInfo{ - Name: header.Name, - Size: header.Size, - Data: data, - }) - } - - return files, nil -} - -func validateCBTPassword(filePath, password string) bool { - tr, err := openEncryptedTar(filePath, password) - if err != nil { - return false - } - - // Try to read first header - _, err = tr.Next() - return err == nil || err == io.EOF -} - -// Add this function to generate cover from CBT -func generateCBTCover(comic *Comic, cacheFile string) error { - var tr *tar.Reader - var err error - var closeFile func() - - // Check if encrypted - encrypted, _ := isTarEncrypted(comic.FilePath) - - if encrypted { - if comic.Password == "" { - return fmt.Errorf("password required") - } - tr, err = openEncryptedTar(comic.FilePath, comic.Password) - closeFile = func() {} // File already closed in openEncryptedTar - } else { - f, err := os.Open(comic.FilePath) - if err != nil { - return err - } - closeFile = func() { f.Close() } - tr = tar.NewReader(f) - } - defer closeFile() - - if err != nil { - return err - } - - // Find first image file - var imageData []byte - // var imageExt string - - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return err - } - - if header.Typeflag == tar.TypeDir { - continue - } - - ext := strings.ToLower(filepath.Ext(header.Name)) - if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || - ext == ".gif" || ext == ".avif" || ext == ".webp" || - ext == ".bmp" || ext == ".jp2" || ext == ".jxl" { - - // Found an image, read it - imageData, err = io.ReadAll(tr) - if err != nil { - return err - } - // imageExt = ext - break - } - } - - if len(imageData) == 0 { - return fmt.Errorf("no images found in tar") - } - - // Decode image - img, _, err := image.Decode(bytes.NewReader(imageData)) - if err != nil { - return err - } - - // Resize - bounds := img.Bounds() - width := bounds.Dx() - height := bounds.Dy() - - maxDim := 300 - 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)) - } - - resized := resize.Resize(uint(newWidth), uint(newHeight), img, resize.Lanczos3) - - // Save as JPEG - out, err := os.Create(cacheFile) - if err != nil { - return err - } - defer out.Close() - - return jpeg.Encode(out, resized, &jpeg.Options{Quality: 75}) -} - -// Add this function to serve CBT pages -func serveCBTPage(w http.ResponseWriter, r *http.Request, comic Comic, pageNum string) { - var tr *tar.Reader - var err error - var closeFile func() - - // Check if encrypted - encrypted, _ := isTarEncrypted(comic.FilePath) - - if encrypted { - passwordsMutex.RLock() - password, hasPassword := comicPasswords[comic.ID] - passwordsMutex.RUnlock() - - if !hasPassword { - http.Error(w, "Password required", http.StatusUnauthorized) - return - } - - tr, err = openEncryptedTar(comic.FilePath, password) - closeFile = func() {} - } else { - f, err := os.Open(comic.FilePath) - if err != nil { - http.Error(w, "Error opening comic", http.StatusInternalServerError) - return - } - closeFile = func() { f.Close() } - tr = tar.NewReader(f) - } - defer closeFile() - - if err != nil { - http.Error(w, "Error reading comic", http.StatusInternalServerError) - return - } - - // Get all image files - var imageFiles []TarFileInfo - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - http.Error(w, "Error reading tar", http.StatusInternalServerError) - return - } - - if header.Typeflag == tar.TypeDir { - continue - } - - ext := strings.ToLower(filepath.Ext(header.Name)) - if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || - ext == ".gif" || ext == ".avif" || ext == ".webp" || - ext == ".bmp" || ext == ".jp2" || ext == ".jxl" { - - data, err := io.ReadAll(tr) - if err != nil { - continue - } - - imageFiles = append(imageFiles, TarFileInfo{ - Name: header.Name, - Size: header.Size, - Data: data, - }) - } - } - - // Sort by name - sort.Slice(imageFiles, func(i, j int) bool { - return imageFiles[i].Name < imageFiles[j].Name - }) - - var pageIdx int - fmt.Sscanf(pageNum, "%d", &pageIdx) - - if pageIdx < 0 || pageIdx >= len(imageFiles) { - http.Error(w, "Page not found", http.StatusNotFound) - return - } - - targetFile := imageFiles[pageIdx] - 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(targetFile.Data) -} - -// Add this function to get CBT page count -func getCBTPageCount(comic Comic) (int, error) { - var tr *tar.Reader - var err error - var closeFile func() - - // Check if encrypted - encrypted, _ := isTarEncrypted(comic.FilePath) - - if encrypted { - passwordsMutex.RLock() - password, hasPassword := comicPasswords[comic.ID] - passwordsMutex.RUnlock() - - if !hasPassword { - return 0, fmt.Errorf("password required") - } - - tr, err = openEncryptedTar(comic.FilePath, password) - closeFile = func() {} - } else { - f, err := os.Open(comic.FilePath) - if err != nil { - return 0, err - } - closeFile = func() { f.Close() } - tr = tar.NewReader(f) - } - defer closeFile() - - if err != nil { - return 0, err - } - - count := 0 - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return 0, err - } - - if header.Typeflag == tar.TypeDir { - continue - } - - ext := strings.ToLower(filepath.Ext(header.Name)) - if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || - ext == ".gif" || ext == ".avif" || ext == ".webp" || - ext == ".bmp" || ext == ".jp2" || ext == ".jxl" { - count++ - } - } - - return count, nil -} - func generateCoverCacheLazy(comic *Comic, cacheFile string) error { // CRITICAL: Set very aggressive GC for this operation oldGC := debug.SetGCPercent(10) @@ -1824,7 +1129,7 @@ func handleTryKnownPasswords(w http.ResponseWriter, r *http.Request) { c := comics[decodedID] c.Password = validPassword c.HasPassword = true - extractMetadata(&c) + extractCBZMetadata(&c) tagsMutex.Lock() for _, tag := range c.Tags { @@ -1911,7 +1216,7 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) { return } - // Use the validation function + // Use the new validation function if !validatePassword(comic.FilePath, req.Password) { http.Error(w, "Invalid password", http.StatusBadRequest) return @@ -1929,16 +1234,10 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) { comicPasswords[decodedID] = req.Password passwordsMutex.Unlock() - // Extract metadata with the valid password + // NOW extract metadata with the valid password comicsMutex.Lock() c = comics[decodedID] - - // IMPORTANT: Set password before extraction so it can decrypt - if c.FileType == ".cbt" { - extractCBTMetadata(&c) - } else if c.FileType == ".cbz" { - extractCBZMetadataInternal(&c) - } + extractCBZMetadata(&c) // Update tags tagsMutex.Lock() @@ -1986,64 +1285,17 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) { } func validatePassword(filePath string, password string) bool { - ext := strings.ToLower(filepath.Ext(filePath)) - - if ext == ".cbt" { - return validateCBTPassword(filePath, password) + yr, err := yzip.OpenReader(filePath) + if err != nil { + return false } + defer yr.Close() - if ext == ".cbz" { - yr, err := yzip.OpenReader(filePath) - if err != nil { - return false - } - defer yr.Close() - - // Try ComicInfo.xml first - for _, f := range yr.File { - if strings.ToLower(f.Name) == "comicinfo.xml" { - if !f.IsEncrypted() { - return true - } - - f.SetPassword(password) - rc, err := f.Open() - if err != nil { - return false - } - - buf := make([]byte, 100) - n, err := rc.Read(buf) - rc.Close() - - if err != nil && err != io.EOF { - return false - } - - if n > 0 && strings.Contains(string(buf[:n]), " 0 && strings.Contains(string(buf[:n]), "
-
-

Upload Comic

-
- - -
- -
+
+

Upload Comic

+
+ + +
+ +

Filter by Tags

diff --git a/scripts-bash/comic-page-number.sh b/bash-scripts/comic-page-number.sh similarity index 100% rename from scripts-bash/comic-page-number.sh rename to bash-scripts/comic-page-number.sh diff --git a/scripts-bash/make-cbz-imagemagick.sh b/bash-scripts/make-cbz-imagemagick.sh similarity index 100% rename from scripts-bash/make-cbz-imagemagick.sh rename to bash-scripts/make-cbz-imagemagick.sh diff --git a/scripts-bash/run.sh b/bash-scripts/run.sh similarity index 94% rename from scripts-bash/run.sh rename to bash-scripts/run.sh index 09971b9..ee514eb 100755 --- a/scripts-bash/run.sh +++ b/bash-scripts/run.sh @@ -12,7 +12,7 @@ if [ $? -ne 0 ]; then fi # Ensure directories exist with correct permissions -mkdir -p ./library ./cache ./etc ./watch +mkdir -p ./library ./cache ./etc if podman container exists "$CONTAINER_NAME"; then echo "Container '$CONTAINER_NAME' already exists. Stopping and removing it..." @@ -29,7 +29,6 @@ podman run -d --name "$CONTAINER_NAME" \ -v ./library:/app/library \ -v ./cache:/app/cache \ -v ./etc:/app/etc \ - -v ./watch:/app/watch \ "$IMAGE_NAME" if [ $? -ne 0 ]; then diff --git a/scripts-bash/cbt.sh b/scripts-bash/cbt.sh deleted file mode 100755 index 7501a35..0000000 --- a/scripts-bash/cbt.sh +++ /dev/null @@ -1,204 +0,0 @@ -#!/bin/bash - -# CBT (Comic Book Tar) Creator and Extractor -# Creates and extracts .cbt files with optional AES encryption -# Compatible with Go server encryption format - -set -e - -show_usage() { - echo "Usage:" - echo " Create: $0 create -i -o [-p ]" - echo " Extract: $0 extract -i -o [-p ]" - echo "" - echo "Options:" - echo " -i Input directory/file" - echo " -o Output file/directory" - echo " -p Password for encryption/decryption (optional)" - echo "" - echo "Examples:" - echo " $0 create -i ./comics -o mycomic.cbt" - echo " $0 create -i ./comics -o mycomic.cbt -p mysecretpass" - echo " $0 extract -i mycomic.cbt -o ./extracted" - echo " $0 extract -i mycomic.cbt -o ./extracted -p mysecretpass" - echo "" - exit 1 -} - -# Parse command line arguments -MODE="$1" -shift || show_usage - -INPUT="" -OUTPUT="" -PASSWORD="" - -while getopts "i:o:p:h" opt; do - case $opt in - i) INPUT="$OPTARG" ;; - o) OUTPUT="$OPTARG" ;; - p) PASSWORD="$OPTARG" ;; - h) show_usage ;; - *) show_usage ;; - esac -done - -# Validate mode -if [ "$MODE" != "create" ] && [ "$MODE" != "extract" ]; then - echo "Error: First argument must be 'create' or 'extract'" - show_usage -fi - -# Validate required arguments -if [ -z "$INPUT" ] || [ -z "$OUTPUT" ]; then - echo "Error: Input and output are required" - show_usage -fi - -# Encrypt data using AES-CFB -encrypt_data() { - local input_file="$1" - local output_file="$2" - local password="$3" - - # Derive key using SHA256 (same as Go) - local key=$(echo -n "$password" | sha256sum | cut -d' ' -f1 | xxd -r -p | base64) - - # Generate random IV (16 bytes for AES) - local iv=$(openssl rand -base64 16) - - # Encrypt using AES-256-CFB - # First write IV, then encrypted data - echo -n "$iv" | base64 -d > "$output_file" - openssl enc -aes-256-cfb -K "$(echo -n "$password" | sha256sum | cut -d' ' -f1)" -iv "$(echo -n "$iv" | base64 -d | xxd -p -c 256)" -in "$input_file" >> "$output_file" -} - -# Decrypt data using AES-CFB (compatible with Go implementation) -decrypt_data() { - local input_file="$1" - local output_file="$2" - local password="$3" - - # Extract IV (first 16 bytes) - local iv_hex=$(head -c 16 "$input_file" | xxd -p -c 256) - - # Extract ciphertext (rest of file) - tail -c +17 "$input_file" > "${output_file}.tmp" - - # Decrypt using AES-256-CFB - if ! openssl enc -d -aes-256-cfb -K "$(echo -n "$password" | sha256sum | cut -d' ' -f1)" -iv "$iv_hex" -in "${output_file}.tmp" -out "$output_file" 2>/dev/null; then - rm -f "${output_file}.tmp" - return 1 - fi - - rm -f "${output_file}.tmp" - return 0 -} - -# CREATE MODE -create_cbt() { - INPUT_DIR="$INPUT" - OUTPUT_FILE="$OUTPUT" - - # Check if input directory exists - if [ ! -d "$INPUT_DIR" ]; then - echo "Error: Input directory '$INPUT_DIR' does not exist" - exit 1 - fi - - # Ensure output has .cbt extension - if [[ ! "$OUTPUT_FILE" =~ \.cbt$ ]]; then - OUTPUT_FILE="${OUTPUT_FILE}.cbt" - fi - - # Create unencrypted tar archive - echo "Creating tar archive from $INPUT_DIR..." - TMP_TAR=$(mktemp) - - # Create tar file, preserving relative paths - tar -cf "$TMP_TAR" -C "$INPUT_DIR" . - - # List files that were added - echo "" - echo "Files added:" - tar -tf "$TMP_TAR" - echo "" - - if [ -n "$PASSWORD" ]; then - # Encrypted mode - echo "Encrypting with AES-CFB (Go-compatible format)..." - - encrypt_data "$TMP_TAR" "$OUTPUT_FILE" "$PASSWORD" - - rm "$TMP_TAR" - echo "Created encrypted CBT: $OUTPUT_FILE" - else - # Unencrypted mode - just move the tar file - mv "$TMP_TAR" "$OUTPUT_FILE" - echo "Created unencrypted CBT: $OUTPUT_FILE" - fi - - echo "Done!" -} - -# EXTRACT MODE -extract_cbt() { - INPUT_FILE="$INPUT" - OUTPUT_DIR="$OUTPUT" - - # Check if input file exists - if [ ! -f "$INPUT_FILE" ]; then - echo "Error: Input file '$INPUT_FILE' does not exist" - exit 1 - fi - - # Create output directory if it doesn't exist - mkdir -p "$OUTPUT_DIR" - - TMP_TAR=$(mktemp) - - # Check if file is encrypted by trying to read as tar first - if tar -tf "$INPUT_FILE" >/dev/null 2>&1; then - # File is unencrypted tar - cp "$INPUT_FILE" "$TMP_TAR" - else - # File appears to be encrypted - if [ -z "$PASSWORD" ]; then - echo "Error: File appears to be encrypted. Please provide password with -p flag." - rm "$TMP_TAR" - exit 1 - fi - - echo "Decrypting $INPUT_FILE..." - - if ! decrypt_data "$INPUT_FILE" "$TMP_TAR" "$PASSWORD"; then - echo "Error: Failed to decrypt. Wrong password or corrupted file." - exit 1 - fi - - echo "Decryption successful!" - fi - - # Extract tar archive - echo "Extracting to $OUTPUT_DIR..." - - if tar -xf "$TMP_TAR" -C "$OUTPUT_DIR" 2>/dev/null; then - echo "" - echo "Files extracted:" - tar -tf "$TMP_TAR" - echo "" - echo "Extraction complete!" - else - rm "$TMP_TAR" - echo "Error: Failed to extract. File may be corrupted." - exit 1 - fi - - rm "$TMP_TAR" -} - -if [ "$MODE" = "create" ]; then - create_cbt -elif [ "$MODE" = "extract" ]; then - extract_cbt -fi diff --git a/scripts-bash/test-cbt.sh b/scripts-bash/test-cbt.sh deleted file mode 100755 index 900816a..0000000 --- a/scripts-bash/test-cbt.sh +++ /dev/null @@ -1,3 +0,0 @@ -user="$(gpg2 -qd /home/moo/.local/pdf-keys/userkey)" -mkdir -p out -./cbt.sh create -i $1 -o ./out/$1.cbt -p $user diff --git a/scripts-go/cbt.go b/scripts-go/cbt.go deleted file mode 100644 index 2467ae5..0000000 --- a/scripts-go/cbt.go +++ /dev/null @@ -1,231 +0,0 @@ -package main - -import ( - "archive/tar" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "flag" - "fmt" - "os" - "path/filepath" - "strings" -) - -func main() { - inputDir := flag.String("input", "", "Input directory containing comic files") - outputFile := flag.String("output", "", "Output .cbt file") - password := flag.String("password", "", "Password for encryption (leave empty for no encryption)") - flag.Parse() - - if *inputDir == "" || *outputFile == "" { - fmt.Println("Usage: cbt-creator -input -output [-password ]") - os.Exit(1) - } - - // Ensure output has .cbt extension - if !strings.HasSuffix(strings.ToLower(*outputFile), ".cbt") { - *outputFile += ".cbt" - } - - var err error - if *password != "" { - err = createEncryptedCBT(*inputDir, *outputFile, *password) - fmt.Printf("Created encrypted CBT: %s\n", *outputFile) - } else { - err = createUnencryptedCBT(*inputDir, *outputFile) - fmt.Printf("Created unencrypted CBT: %s\n", *outputFile) - } - - if err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } -} - -func createUnencryptedCBT(inputDir, outputPath string) error { - outFile, err := os.Create(outputPath) - if err != nil { - return err - } - defer outFile.Close() - - tw := tar.NewWriter(outFile) - defer tw.Close() - - // Collect all files - var files []string - err = filepath.Walk(inputDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() { - files = append(files, path) - } - return nil - }) - - if err != nil { - return err - } - - // Add files to tar - for _, file := range files { - relPath, err := filepath.Rel(inputDir, file) - if err != nil { - return err - } - - // Read file - data, err := os.ReadFile(file) - if err != nil { - return err - } - - // Write tar header - hdr := &tar.Header{ - Name: relPath, - Mode: 0600, - Size: int64(len(data)), - } - - if err := tw.WriteHeader(hdr); err != nil { - return err - } - - // Write file data - if _, err := tw.Write(data); err != nil { - return err - } - - fmt.Printf("Added: %s\n", relPath) - } - - return nil -} - -func createEncryptedCBT(inputDir, outputPath, password string) error { - // Create temporary unencrypted tar - tmpTar := outputPath + ".tmp" - tmpFile, err := os.Create(tmpTar) - if err != nil { - return err - } - - tw := tar.NewWriter(tmpFile) - - // Collect all files - var files []string - err = filepath.Walk(inputDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() { - files = append(files, path) - } - return nil - }) - - if err != nil { - tmpFile.Close() - os.Remove(tmpTar) - return err - } - - // Add files to tar - for _, file := range files { - relPath, err := filepath.Rel(inputDir, file) - if err != nil { - tw.Close() - tmpFile.Close() - os.Remove(tmpTar) - return err - } - - // Read file - data, err := os.ReadFile(file) - if err != nil { - tw.Close() - tmpFile.Close() - os.Remove(tmpTar) - return err - } - - // Write tar header - hdr := &tar.Header{ - Name: relPath, - Mode: 0600, - Size: int64(len(data)), - } - - if err := tw.WriteHeader(hdr); err != nil { - tw.Close() - tmpFile.Close() - os.Remove(tmpTar) - return err - } - - // Write file data - if _, err := tw.Write(data); err != nil { - tw.Close() - tmpFile.Close() - os.Remove(tmpTar) - return err - } - - fmt.Printf("Added: %s\n", relPath) - } - - tw.Close() - tmpFile.Close() - - // Read the tar file - tarData, err := os.ReadFile(tmpTar) - if err != nil { - os.Remove(tmpTar) - return err - } - - fmt.Println("Encrypting...") - - // Encrypt - key := deriveKey(password) - encrypted, err := encryptAES(tarData, key) - if err != nil { - os.Remove(tmpTar) - return err - } - - // Write encrypted file - err = os.WriteFile(outputPath, encrypted, 0644) - os.Remove(tmpTar) - - return err -} - -func deriveKey(password string) []byte { - hash := sha256.Sum256([]byte(password)) - return hash[:] -} - -func encryptAES(plaintext []byte, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - // Generate IV - iv := make([]byte, aes.BlockSize) - if _, err := rand.Read(iv); err != nil { - return nil, err - } - - stream := cipher.NewCFBEncrypter(block, iv) - - ciphertext := make([]byte, len(plaintext)) - stream.XORKeyStream(ciphertext, plaintext) - - // Prepend IV to ciphertext - return append(iv, ciphertext...), nil -}