diff --git a/.containerignore b/.containerignore index 3a14218..b06848b 100644 --- a/.containerignore +++ b/.containerignore @@ -9,3 +9,4 @@ xml-template cache etc library +watch diff --git a/.gitignore b/.gitignore index 0d59517..e206118 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ cache library etc +watch diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1cd8552 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +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 d9c4a95..b333156 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Gopherbook – Self-Hosted Comic Library & CBZ Reader +# Gopherbook – Self-Hosted Comic Library & CBZ/CBT Reader Gopherbook is a lightweight, single-binary, self-hosted web comic reader and library manager written in Go. -It is designed for people who want full control over their digital comic collection (CBZ files), including support for password-protected/encrypted archives, per-user libraries, tagging, automatic organization, and a clean modern reader. +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. ## License @@ -9,10 +9,11 @@ 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 +- 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 - 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) +- Full support for password-protected/encrypted CBZ files (AES-256 via yeka/zip) or CBT files (AES-256-CFB Openssl) - 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.) @@ -42,7 +43,6 @@ 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,11 +53,10 @@ 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 -./bash-scripts/run.sh +./scripts-bash/run.sh ``` Then open http://localhost:12010 in your browser. @@ -65,23 +64,56 @@ Then open http://localhost:12010 in your browser. ### First launch 1. On first run there are no users → registration is open 2. Create the first account → this user automatically becomes admin -3. Log in → start uploading CBZ files +3. Log in → start uploading CBZ/CBT 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 that has no known password yet, the server marks it as Encrypted = true. +- When you upload or scan an encrypted CBZ/CBT 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) @@ -152,7 +184,7 @@ Please open an issue first for bigger changes. - Everyone who hoards comics ❤️ Enjoy your library! -— Happy reading with Gopherbook +– Happy reading with Gopherbook
@@ -160,6 +192,7 @@ 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 b9ab47b..87bab00 100644 --- a/app/gopherbook/main.go +++ b/app/gopherbook/main.go @@ -1,6 +1,8 @@ package main import ( + "archive/tar" + "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -96,6 +98,12 @@ 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) @@ -114,14 +122,19 @@ 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) @@ -139,6 +152,7 @@ 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() { @@ -209,6 +223,9 @@ 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"}) } @@ -261,6 +278,233 @@ 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") @@ -319,6 +563,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { loadTags() loadPasswordsWithKey(key) currentEncryptionKey = key + startWatchingUser(req.Username) http.SetCookie(w, &http.Cookie{ Name: "session", @@ -412,62 +657,66 @@ 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 + } - // 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 - } + 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() - destPath := filepath.Join(libraryPath, "Unorganized", filename) - destFile, err := os.Create(destPath) - if err != nil { - http.Error(w, "Error saving file", http.StatusInternalServerError) - return - } + if part.FormName() == "file" { + filename := part.FileName() - // 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 - } + // 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 + } - fileInfo, _ := os.Stat(destPath) - comic := processComic(destPath, filename, fileInfo.ModTime()) + destPath := filepath.Join(libraryPath, "Unorganized", filename) + destFile, err := os.Create(destPath) + if err != nil { + http.Error(w, "Error saving file", http.StatusInternalServerError) + return + } - comicsMutex.Lock() - comics[comic.ID] = comic - comicsMutex.Unlock() + 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 + } - // FIX: Force GC after the write is finished - buf = nil - runtime.GC() + fileInfo, _ := os.Stat(destPath) + comic := processComic(destPath, filename, fileInfo.ModTime()) - json.NewEncoder(w).Encode(comic) - return - } - } + comicsMutex.Lock() + comics[comic.ID] = comic + comicsMutex.Unlock() + + buf = nil + runtime.GC() + + json.NewEncoder(w).Encode(comic) + return + } + } } func logMemStats(label string) { @@ -572,28 +821,34 @@ func handleCover(w http.ResponseWriter, r *http.Request) { log.Printf("Generating cover on-demand for: %s", comic.Filename) - // NEW: Use a channel-based semaphore for better control + // Use semaphore for cover generation 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) + // Double-check cache again 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) + // 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) http.Error(w, "Failed to generate cover", http.StatusInternalServerError) return } @@ -608,6 +863,446 @@ 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) @@ -1129,7 +1824,7 @@ func handleTryKnownPasswords(w http.ResponseWriter, r *http.Request) { c := comics[decodedID] c.Password = validPassword c.HasPassword = true - extractCBZMetadata(&c) + extractMetadata(&c) tagsMutex.Lock() for _, tag := range c.Tags { @@ -1216,7 +1911,7 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) { return } - // Use the new validation function + // Use the validation function if !validatePassword(comic.FilePath, req.Password) { http.Error(w, "Invalid password", http.StatusBadRequest) return @@ -1234,10 +1929,16 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) { comicPasswords[decodedID] = req.Password passwordsMutex.Unlock() - // NOW extract metadata with the valid password + // Extract metadata with the valid password comicsMutex.Lock() c = comics[decodedID] - extractCBZMetadata(&c) + + // IMPORTANT: Set password before extraction so it can decrypt + if c.FileType == ".cbt" { + extractCBTMetadata(&c) + } else if c.FileType == ".cbz" { + extractCBZMetadataInternal(&c) + } // Update tags tagsMutex.Lock() @@ -1285,17 +1986,64 @@ func handleSetPassword(w http.ResponseWriter, r *http.Request) { } func validatePassword(filePath string, password string) bool { - yr, err := yzip.OpenReader(filePath) - if err != nil { - return false - } - defer yr.Close() + ext := strings.ToLower(filepath.Ext(filePath)) + + if ext == ".cbt" { + return validateCBTPassword(filePath, password) + } + + 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/cbt.sh b/scripts-bash/cbt.sh new file mode 100755 index 0000000..7501a35 --- /dev/null +++ b/scripts-bash/cbt.sh @@ -0,0 +1,204 @@ +#!/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/bash-scripts/comic-page-number.sh b/scripts-bash/comic-page-number.sh similarity index 100% rename from bash-scripts/comic-page-number.sh rename to scripts-bash/comic-page-number.sh diff --git a/bash-scripts/make-cbz-imagemagick.sh b/scripts-bash/make-cbz-imagemagick.sh similarity index 100% rename from bash-scripts/make-cbz-imagemagick.sh rename to scripts-bash/make-cbz-imagemagick.sh diff --git a/bash-scripts/run.sh b/scripts-bash/run.sh similarity index 94% rename from bash-scripts/run.sh rename to scripts-bash/run.sh index ee514eb..09971b9 100755 --- a/bash-scripts/run.sh +++ b/scripts-bash/run.sh @@ -12,7 +12,7 @@ if [ $? -ne 0 ]; then fi # Ensure directories exist with correct permissions -mkdir -p ./library ./cache ./etc +mkdir -p ./library ./cache ./etc ./watch if podman container exists "$CONTAINER_NAME"; then echo "Container '$CONTAINER_NAME' already exists. Stopping and removing it..." @@ -29,6 +29,7 @@ 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/test-cbt.sh b/scripts-bash/test-cbt.sh new file mode 100755 index 0000000..900816a --- /dev/null +++ b/scripts-bash/test-cbt.sh @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..2467ae5 --- /dev/null +++ b/scripts-go/cbt.go @@ -0,0 +1,231 @@ +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 +}