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!


-



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