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!


+



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
+}