Merge pull request 'merge: planned additions' (#5) from dev/finalized-features into main

Reviewed-on: #5
This commit is contained in:
riomoo 2026-01-06 02:04:41 -05:00 committed by moobot
commit 1f288ea32b
Signed by: moobot
GPG key ID: 1F58B1369E1C199C
12 changed files with 1427 additions and 189 deletions

View file

@ -9,3 +9,4 @@ xml-template
cache cache
etc etc
library library
watch

1
.gitignore vendored
View file

@ -6,3 +6,4 @@
cache cache
library library
etc etc
watch

5
Makefile Normal file
View file

@ -0,0 +1,5 @@
build:
go build -o bin/main app/gopherbook/main.go
clean:
rm -rf watch etc library cache

View file

@ -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. 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 ## License
@ -9,10 +9,11 @@ It is designed for people who want full control over their digital comic collect
## Features ## 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 - Supports 8 Megapixel images at 512MB memory limits
- (increase in bash script for higher Megapixels or remove the limitation if you don't care) - (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 - 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) - 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.) - 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) - Or just download a pre-built binary from Releases (when available)
### Quick start (from source) ### Quick start (from source)
```bash ```bash
git clone https://codeberg.org/riomoo/gopherbook.git git clone https://codeberg.org/riomoo/gopherbook.git
cd gopherbook cd gopherbook
@ -53,11 +53,10 @@ go build -o gopherbook app/gopherbook/main.go
Then open http://localhost:8080 in your browser. Then open http://localhost:8080 in your browser.
## If you want to use this with podman: ## If you want to use this with podman:
```bash ```bash
git clone https://codeberg.org/riomoo/gopherbook.git git clone https://codeberg.org/riomoo/gopherbook.git
cd gopherbook cd gopherbook
./bash-scripts/run.sh ./scripts-bash/run.sh
``` ```
Then open http://localhost:12010 in your browser. Then open http://localhost:12010 in your browser.
@ -65,23 +64,56 @@ Then open http://localhost:12010 in your browser.
### First launch ### First launch
1. On first run there are no users → registration is open 1. On first run there are no users → registration is open
2. Create the first account → this user automatically becomes admin 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 ## Directory layout after first login
``` ```
./library/username/ ← your comics (organized or Unorganized/) ./library/username/ ← your comics (organized or Unorganized/)
./library/username/comics.json ← metadata index ./library/username/comics.json ← metadata index
./library/username/tags.json ← tag definitions & counts ./library/username/tags.json ← tag definitions & counts
./library/username/passwords.json ← encrypted password vault (AES) ./library/username/passwords.json ← encrypted password vault (AES)
./cache/covers/username/ ← generated cover thumbnails ./cache/covers/username/ ← generated cover thumbnails
./watch/username/ ← watch folder for bulk imports (auto-scanned)
./etc/users.json ← user accounts (bcrypt hashes) ./etc/users.json ← user accounts (bcrypt hashes)
./etc/admin.json ← admin settings (registration toggle) ./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 ## 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. - The first time you open it in the reader, a password prompt appears.
- If the password is correct, Gopherbook: - If the password is correct, Gopherbook:
- Stores the password (encrypted with a key derived from your login password) - 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 ❤️ - Everyone who hoards comics ❤️
Enjoy your library! Enjoy your library!
Happy reading with Gopherbook Happy reading with Gopherbook
<div align="center"> <div align="center">
@ -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) ![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) ![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) ![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) ![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) ![Forgejo](https://img.shields.io/badge/forgejo-%23FB923C.svg?style=for-the-badge&logo=forgejo&logoColor=white)

File diff suppressed because it is too large Load diff

View file

@ -772,8 +772,8 @@
<div class="upload-section"> <div class="upload-section">
<h2 style="margin-bottom: 16px; color: #c9d1d9;">Upload Comic</h2> <h2 style="margin-bottom: 16px; color: #c9d1d9;">Upload Comic</h2>
<div class="form-group"> <div class="form-group">
<label>Select File (CBZ)</label> <label>Select File (CBZ or CBT)</label>
<input type="file" id="fileUpload" accept=".cbz"> <input type="file" id="fileUpload" accept=".cbz,.cbt">
</div> </div>
<button onclick="uploadComic()">Upload</button> <button onclick="uploadComic()">Upload</button>
</div> </div>

204
scripts-bash/cbt.sh Executable file
View file

@ -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 <input_directory> -o <output_file> [-p <password>]"
echo " Extract: $0 extract -i <input_file> -o <output_directory> [-p <password>]"
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

View file

@ -12,7 +12,7 @@ if [ $? -ne 0 ]; then
fi fi
# Ensure directories exist with correct permissions # Ensure directories exist with correct permissions
mkdir -p ./library ./cache ./etc mkdir -p ./library ./cache ./etc ./watch
if podman container exists "$CONTAINER_NAME"; then if podman container exists "$CONTAINER_NAME"; then
echo "Container '$CONTAINER_NAME' already exists. Stopping and removing it..." 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 ./library:/app/library \
-v ./cache:/app/cache \ -v ./cache:/app/cache \
-v ./etc:/app/etc \ -v ./etc:/app/etc \
-v ./watch:/app/watch \
"$IMAGE_NAME" "$IMAGE_NAME"
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then

3
scripts-bash/test-cbt.sh Executable file
View file

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

231
scripts-go/cbt.go Normal file
View file

@ -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 <directory> -output <file.cbt> [-password <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
}