2961 lines
66 KiB
Go
2961 lines
66 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"io/fs"
|
|
"sync"
|
|
"time"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"image"
|
|
"image/jpeg"
|
|
_ "image/gif"
|
|
_ "image/png"
|
|
|
|
"github.com/nfnt/resize"
|
|
_ "github.com/gen2brain/avif"
|
|
"golang.org/x/crypto/bcrypt"
|
|
yzip "github.com/yeka/zip"
|
|
)
|
|
|
|
//go:embed templates/index.html
|
|
var templateFS embed.FS
|
|
|
|
//go:embed all:static
|
|
var staticFS embed.FS
|
|
|
|
// ComicInfo represents the standard ComicInfo.xml metadata
|
|
type ComicInfo struct {
|
|
XMLName xml.Name `xml:"ComicInfo"`
|
|
Title string `xml:"Title"`
|
|
Series string `xml:"Series"`
|
|
Number string `xml:"Number"`
|
|
Writer string `xml:"Writer"`
|
|
Artist string `xml:"Artist"`
|
|
Inker string `xml:"Inker"`
|
|
Publisher string `xml:"Publisher"`
|
|
Genre string `xml:"Genre"`
|
|
TagsXml string `xml:"Tags"`
|
|
StoryArc string `xml:"StoryArc"`
|
|
Year string `xml:"Year"`
|
|
Month string `xml:"Month"`
|
|
Summary string `xml:"Summary"`
|
|
PageCount int `xml:"PageCount"`
|
|
}
|
|
|
|
type User struct {
|
|
Username string `json:"username"`
|
|
PasswordHash string `json:"password_hash"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
}
|
|
|
|
type Comic struct {
|
|
ID string `json:"id"`
|
|
Filename string `json:"filename"`
|
|
Artist string `json:"artist"`
|
|
Title string `json:"title"`
|
|
Series string `json:"series"`
|
|
StoryArc string `json:"story_arc"`
|
|
Number string `json:"number"`
|
|
Publisher string `json:"publisher"`
|
|
Year string `json:"year"`
|
|
PageCount int `json:"page_count"`
|
|
CoverImage string `json:"cover_image"`
|
|
FilePath string `json:"file_path"`
|
|
FileType string `json:"file_type"`
|
|
Encrypted bool `json:"encrypted"`
|
|
HasPassword bool `json:"has_password"`
|
|
Password string `json:"-"`
|
|
Tags []string `json:"tags"`
|
|
UploadedAt time.Time `json:"uploaded_at"`
|
|
Bookmarks []int `json:"bookmarks"`
|
|
LastModified time.Time `json:"last_modified"`
|
|
}
|
|
|
|
type Session struct {
|
|
Username string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
type Tag struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type TarFileInfo struct {
|
|
Name string
|
|
Size int64
|
|
Data []byte
|
|
}
|
|
|
|
var (
|
|
users = make(map[string]User)
|
|
sessions = make(map[string]Session)
|
|
comics = make(map[string]Comic)
|
|
tags = make(map[string]Tag)
|
|
comicPasswords = make(map[string]string)
|
|
coverGenSemaphore = make(chan struct{}, 1) // Only ONE cover generation at a time
|
|
comicsMutex sync.RWMutex
|
|
sessionsMutex sync.RWMutex
|
|
tagsMutex sync.RWMutex
|
|
passwordsMutex sync.RWMutex
|
|
currentEncryptionKey []byte
|
|
libraryPath = "./library"
|
|
cachePath = "./cache/covers"
|
|
etcPath = "./etc"
|
|
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()
|
|
// Create static sub-filesystem once
|
|
staticSubFS, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
log.Fatal(fmt.Errorf("failed to create static sub-filesystem: %w", err))
|
|
}
|
|
|
|
// Create handlers once and reuse
|
|
staticHandler := http.FileServer(http.FS(staticSubFS))
|
|
|
|
http.HandleFunc("/api/register", handleRegister)
|
|
http.HandleFunc("/api/login", handleLogin)
|
|
http.HandleFunc("/api/logout", handleLogout)
|
|
http.HandleFunc("/api/comics", authMiddleware(handleComics))
|
|
http.HandleFunc("/api/upload", authMiddleware(handleUpload))
|
|
http.HandleFunc("/api/user", authMiddleware(handleUser))
|
|
http.HandleFunc("/api/pages/", authMiddleware(handleComicPages))
|
|
http.HandleFunc("/api/comic/", authMiddleware(handleComicFile))
|
|
http.HandleFunc("/api/cover/", authMiddleware(handleCover))
|
|
http.HandleFunc("/api/tags", authMiddleware(handleTags))
|
|
http.HandleFunc("/api/comic-tags/", authMiddleware(handleComicTags))
|
|
http.HandleFunc("/api/set-password/", authMiddleware(handleSetPassword))
|
|
http.HandleFunc("/api/try-passwords/", authMiddleware(handleTryKnownPasswords))
|
|
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)
|
|
http.Handle("/static/", http.StripPrefix("/static/", staticHandler))
|
|
|
|
go func() {
|
|
for {
|
|
time.Sleep(30 * time.Second)
|
|
runtime.GC()
|
|
debug.FreeOSMemory()
|
|
}
|
|
}()
|
|
|
|
// Periodic session cleanup
|
|
go cleanupSessions()
|
|
|
|
log.Println("Server starting on :8080")
|
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
}
|
|
|
|
func handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !registrationEnabled {
|
|
http.Error(w, "Registration disabled", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Username == "" || req.Password == "" {
|
|
http.Error(w, "Username and password required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if _, exists := users[req.Username]; exists {
|
|
http.Error(w, "User already exists", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
http.Error(w, "Error creating user", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
users[req.Username] = User{
|
|
Username: req.Username,
|
|
PasswordHash: string(hash),
|
|
IsAdmin: len(users) == 0,
|
|
}
|
|
saveUsers()
|
|
if len(users) == 1 {
|
|
saveAdminConfig()
|
|
registrationEnabled = true
|
|
}
|
|
|
|
userLibrary := filepath.Join("./library", req.Username)
|
|
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"})
|
|
}
|
|
|
|
func handleToggleRegistration(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost && r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
user := getCurrentUser(r)
|
|
if !user.IsAdmin {
|
|
http.Error(w, "Admin only", http.StatusForbidden)
|
|
return
|
|
}
|
|
if r.Method == http.MethodPost {
|
|
registrationEnabled = !registrationEnabled
|
|
saveAdminConfig()
|
|
debounceSave()
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]bool{"enabled": registrationEnabled})
|
|
}
|
|
|
|
func handleUser(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)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"username": user.Username,
|
|
"is_admin": user.IsAdmin,
|
|
})
|
|
}
|
|
|
|
func getCurrentUser(r *http.Request) User {
|
|
cookie, err := r.Cookie("session")
|
|
if err != nil {
|
|
return User{}
|
|
}
|
|
sessionsMutex.RLock()
|
|
session, exists := sessions[cookie.Value]
|
|
sessionsMutex.RUnlock()
|
|
if !exists {
|
|
return 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")
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
user, exists := users[req.Username]
|
|
if !exists {
|
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
token := generateToken()
|
|
sessionsMutex.Lock()
|
|
sessions[token] = Session{
|
|
Username: req.Username,
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
}
|
|
sessionsMutex.Unlock()
|
|
|
|
currentUser = req.Username
|
|
key := deriveKey(req.Password)
|
|
libraryPath = filepath.Join(baseLibraryPath, currentUser)
|
|
cachePath = filepath.Join(baseCachePath, currentUser)
|
|
os.MkdirAll(filepath.Join(libraryPath, "Unorganized"), 0755)
|
|
os.MkdirAll(cachePath, 0755)
|
|
|
|
comicsMutex.Lock()
|
|
comics = make(map[string]Comic)
|
|
comicsMutex.Unlock()
|
|
tagsMutex.Lock()
|
|
tags = make(map[string]Tag)
|
|
tagsMutex.Unlock()
|
|
passwordsMutex.Lock()
|
|
comicPasswords = make(map[string]string)
|
|
passwordsMutex.Unlock()
|
|
|
|
loadComics()
|
|
loadTags()
|
|
loadPasswordsWithKey(key)
|
|
currentEncryptionKey = key
|
|
startWatchingUser(req.Username)
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: token,
|
|
Expires: time.Now().Add(24 * time.Hour),
|
|
HttpOnly: true,
|
|
Path: "/",
|
|
})
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Login successful",
|
|
"token": token,
|
|
"is_admin": user.IsAdmin,
|
|
})
|
|
}
|
|
|
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err == nil {
|
|
sessionsMutex.Lock()
|
|
delete(sessions, cookie.Value)
|
|
sessionsMutex.Unlock()
|
|
}
|
|
|
|
comicsMutex.Lock()
|
|
comics = make(map[string]Comic)
|
|
comicsMutex.Unlock()
|
|
tagsMutex.Lock()
|
|
tags = make(map[string]Tag)
|
|
tagsMutex.Unlock()
|
|
passwordsMutex.Lock()
|
|
comicPasswords = make(map[string]string)
|
|
passwordsMutex.Unlock()
|
|
currentEncryptionKey = nil
|
|
currentUser = ""
|
|
libraryPath = baseLibraryPath
|
|
cachePath = baseCachePath
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: "",
|
|
Expires: time.Now().Add(-1 * time.Hour),
|
|
HttpOnly: true,
|
|
Path: "/",
|
|
})
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out"})
|
|
}
|
|
|
|
func handleComics(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Trigger scan if needed (lightweight now)
|
|
if len(comics) == 0 {
|
|
scanLibrary()
|
|
}
|
|
|
|
comicsMutex.RLock()
|
|
defer comicsMutex.RUnlock()
|
|
|
|
comicList := make([]Comic, 0, len(comics))
|
|
for _, comic := range comics {
|
|
// Check if we have a password for this comic
|
|
passwordsMutex.RLock()
|
|
_, hasPassword := comicPasswords[comic.ID]
|
|
passwordsMutex.RUnlock()
|
|
|
|
// Update HasPassword flag (lightweight)
|
|
if hasPassword {
|
|
comic.HasPassword = true
|
|
}
|
|
|
|
comicList = append(comicList, comic)
|
|
}
|
|
|
|
sort.Slice(comicList, func(i, j int) bool {
|
|
if comicList[i].Artist != comicList[j].Artist {
|
|
return comicList[i].Artist < comicList[j].Artist
|
|
}
|
|
if comicList[i].Series != comicList[j].Series {
|
|
return comicList[i].Series < comicList[j].Series
|
|
}
|
|
return comicList[i].Number < comicList[j].Number
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(comicList)
|
|
}
|
|
|
|
func handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
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()
|
|
|
|
// 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
|
|
}
|
|
|
|
destPath := filepath.Join(libraryPath, "Unorganized", filename)
|
|
destFile, err := os.Create(destPath)
|
|
if err != nil {
|
|
http.Error(w, "Error saving file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fileInfo, _ := os.Stat(destPath)
|
|
comic := processComic(destPath, filename, fileInfo.ModTime())
|
|
|
|
comicsMutex.Lock()
|
|
comics[comic.ID] = comic
|
|
comicsMutex.Unlock()
|
|
|
|
buf = nil
|
|
runtime.GC()
|
|
|
|
json.NewEncoder(w).Encode(comic)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func logMemStats(label string) {
|
|
var m runtime.MemStats
|
|
runtime.ReadMemStats(&m)
|
|
log.Printf("[%s] Alloc=%dMB, TotalAlloc=%dMB, Sys=%dMB, NumGC=%d",
|
|
label,
|
|
m.Alloc/1024/1024,
|
|
m.TotalAlloc/1024/1024,
|
|
m.Sys/1024/1024,
|
|
m.NumGC)
|
|
}
|
|
|
|
func handleDeleteComic(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
user := getCurrentUser(r)
|
|
if !user.IsAdmin {
|
|
http.Error(w, "Admin only", http.StatusForbidden)
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/admin/delete-comic/")
|
|
decodedID, _ := url.QueryUnescape(id)
|
|
comicsMutex.Lock()
|
|
comic, exists := comics[decodedID]
|
|
if exists {
|
|
os.Remove(comic.FilePath)
|
|
for _, tag := range comic.Tags {
|
|
updateTagCount(tag, -1)
|
|
}
|
|
delete(comics, decodedID)
|
|
debounceSave()
|
|
}
|
|
comicsMutex.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "Deleted"})
|
|
}
|
|
|
|
func handleCover(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/cover/")
|
|
decodedID, err := url.QueryUnescape(id)
|
|
if err != nil {
|
|
decodedID = id
|
|
}
|
|
|
|
comicsMutex.RLock()
|
|
comic, exists := comics[decodedID]
|
|
if !exists {
|
|
comic, exists = comics[id]
|
|
}
|
|
comicsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "Comic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
cacheFile := filepath.Join(cachePath, comic.ID+".jpg")
|
|
|
|
// Check if cache exists
|
|
if _, err := os.Stat(cacheFile); err == nil {
|
|
http.ServeFile(w, r, cacheFile)
|
|
return
|
|
}
|
|
|
|
// Check if we have password for encrypted comics
|
|
passwordsMutex.RLock()
|
|
password, hasPassword := comicPasswords[comic.ID]
|
|
passwordsMutex.RUnlock()
|
|
|
|
if comic.Encrypted && !hasPassword {
|
|
w.WriteHeader(http.StatusLocked)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "password_required",
|
|
"message": "Comic requires password",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Set password if we have it
|
|
if hasPassword {
|
|
comicsMutex.Lock()
|
|
c := comics[comic.ID]
|
|
c.Password = password
|
|
c.HasPassword = true
|
|
comics[comic.ID] = c
|
|
comic = c
|
|
comicsMutex.Unlock()
|
|
}
|
|
|
|
// Load metadata if not already loaded
|
|
if comic.Series == "" && comic.Title == "" {
|
|
loadComicMetadataLazy(comic.ID)
|
|
comicsMutex.RLock()
|
|
comic = comics[comic.ID]
|
|
comicsMutex.RUnlock()
|
|
}
|
|
|
|
log.Printf("Generating cover on-demand for: %s", comic.Filename)
|
|
|
|
// Use semaphore for cover generation
|
|
select {
|
|
case coverGenSemaphore <- struct{}{}:
|
|
defer func() { <-coverGenSemaphore }()
|
|
case <-time.After(30 * time.Second):
|
|
log.Printf("Timeout waiting for cover generation slot")
|
|
http.Error(w, "Cover generation busy, try again later", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Double-check cache again
|
|
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)
|
|
http.Error(w, "Failed to generate cover", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Serve the newly generated cache
|
|
if _, err := os.Stat(cacheFile); err == nil {
|
|
http.ServeFile(w, r, cacheFile)
|
|
return
|
|
}
|
|
|
|
http.Error(w, "Cover generation failed", http.StatusInternalServerError)
|
|
}
|
|
|
|
|
|
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)
|
|
defer func() {
|
|
debug.SetGCPercent(oldGC)
|
|
runtime.GC()
|
|
debug.FreeOSMemory()
|
|
}()
|
|
|
|
if comic.FileType != ".cbz" {
|
|
return fmt.Errorf("not a CBZ file")
|
|
}
|
|
|
|
// Check file size first - refuse to process huge files
|
|
fi, err := os.Stat(comic.FilePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fi.Size() > 900*1024*1024 { // 900MB max CBZ file
|
|
log.Printf("CBZ too large (%d bytes), skipping thumbnail", fi.Size())
|
|
return fmt.Errorf("file too large")
|
|
}
|
|
|
|
yr, err := yzip.OpenReader(comic.FilePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer yr.Close()
|
|
|
|
var imageFiles []*yzip.File
|
|
for _, f := range yr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(f.Name))
|
|
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" ||
|
|
ext == ".avif" || ext == ".jxl" || ext == ".webp" || ext == ".bmp" || ext == ".jp2" {
|
|
imageFiles = append(imageFiles, f)
|
|
}
|
|
}
|
|
|
|
if len(imageFiles) == 0 {
|
|
return fmt.Errorf("no images found")
|
|
}
|
|
|
|
sort.Slice(imageFiles, func(i, j int) bool {
|
|
return imageFiles[i].Name < imageFiles[j].Name
|
|
})
|
|
|
|
coverFile := imageFiles[0]
|
|
|
|
if coverFile.UncompressedSize64 > 30*1024*1024 { // 30MB uncompressed
|
|
log.Printf("Cover image too large (%d bytes), using direct resize", coverFile.UncompressedSize64)
|
|
return resizeCoverDirectly(comic, coverFile, cacheFile, 300)
|
|
}
|
|
|
|
if coverFile.IsEncrypted() {
|
|
if comic.Password != "" {
|
|
coverFile.SetPassword(comic.Password)
|
|
} else {
|
|
return fmt.Errorf("encrypted without password")
|
|
}
|
|
}
|
|
|
|
rc, err := coverFile.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
|
|
// NEW: First decode config to check dimensions
|
|
config, format, err := image.DecodeConfig(rc)
|
|
if err == nil {
|
|
pixelCount := config.Width * config.Height
|
|
log.Printf("Cover dimensions: %dx%d (%d pixels), format: %s", config.Width, config.Height, pixelCount, format)
|
|
|
|
// If image is huge (>20 megapixels), use direct resize
|
|
if pixelCount > 20*1000*1000 {
|
|
rc.Close()
|
|
log.Printf("Image too large (%d megapixels), using direct resize", pixelCount/1000000)
|
|
return resizeCoverDirectly(comic, coverFile, cacheFile, 300)
|
|
}
|
|
}
|
|
rc.Close()
|
|
|
|
// Reopen for actual reading
|
|
rc, err = coverFile.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
|
|
// Stream to temp file with size limit
|
|
tempFile, err := os.CreateTemp(cachePath, "cover-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := tempFile.Name()
|
|
defer os.Remove(tempPath)
|
|
|
|
// Copy with explicit limit
|
|
written, err := io.CopyN(tempFile, rc, 30*1024*1024) // 30MB hard limit
|
|
if err != nil && err != io.EOF {
|
|
tempFile.Close()
|
|
return err
|
|
}
|
|
tempFile.Close()
|
|
|
|
if written == 0 {
|
|
return fmt.Errorf("empty image file")
|
|
}
|
|
|
|
// Force GC before heavy operation
|
|
runtime.GC()
|
|
|
|
// Resize with aggressive memory management
|
|
return resizeImageAggressively(tempPath, cacheFile, 300) // Reduced from 400
|
|
}
|
|
|
|
|
|
// New function: resize directly from reader for huge images
|
|
// NEW: Improved resizeCoverDirectly with streaming decode
|
|
func resizeCoverDirectly(comic *Comic, coverFile *yzip.File, cacheFile string, maxDim int) error {
|
|
if coverFile.IsEncrypted() && comic.Password != "" {
|
|
coverFile.SetPassword(comic.Password)
|
|
}
|
|
|
|
// 1. Extract and decrypt to disk first
|
|
tmp, err := os.CreateTemp("", "cover-extract-*.img")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
defer tmp.Close()
|
|
|
|
rc, err := coverFile.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = io.Copy(tmp, rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Decode from Disk-based reader
|
|
tmp.Seek(0, 0)
|
|
img, _, err := image.Decode(tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp.Close() // Close early
|
|
|
|
// 3. Resize logic
|
|
bounds := img.Bounds()
|
|
width, height := bounds.Dx(), bounds.Dy()
|
|
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))
|
|
}
|
|
|
|
// Use Bilinear for better speed/memory balance on huge 33MP images
|
|
resized := resize.Resize(uint(newWidth), uint(newHeight), img, resize.Bilinear)
|
|
|
|
// CRITICAL: Nil the 132MB bitmap immediately
|
|
img = nil
|
|
runtime.GC()
|
|
|
|
out, err := os.Create(cacheFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
err = jpeg.Encode(out, resized, &jpeg.Options{Quality: 75})
|
|
resized = nil
|
|
runtime.GC()
|
|
|
|
return err
|
|
}
|
|
|
|
|
|
func resizeImageAggressively(inputPath, outputPath string, maxDimension int) error {
|
|
f, err := os.Open(inputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
// First check dimensions WITHOUT decoding full image
|
|
config, format, err := image.DecodeConfig(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("Resizing %s image: %dx%d", format, config.Width, config.Height)
|
|
|
|
// Seek back to start
|
|
f.Seek(0, 0)
|
|
|
|
// Decode with size awareness
|
|
img, _, err := image.Decode(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.Close()
|
|
|
|
bounds := img.Bounds()
|
|
width := bounds.Dx()
|
|
height := bounds.Dy()
|
|
|
|
// Calculate target dimensions
|
|
var newWidth, newHeight int
|
|
if width > height {
|
|
newWidth = maxDimension
|
|
newHeight = int(float64(height) * float64(maxDimension) / float64(width))
|
|
} else {
|
|
newHeight = maxDimension
|
|
newWidth = int(float64(width) * float64(maxDimension) / float64(height))
|
|
}
|
|
|
|
if newWidth < 1 {
|
|
newWidth = 1
|
|
}
|
|
if newHeight < 1 {
|
|
newHeight = 1
|
|
}
|
|
|
|
// Choose resize method based on size ratio
|
|
var resizeMethod resize.InterpolationFunction
|
|
ratio := float64(width*height) / float64(newWidth*newHeight)
|
|
|
|
if ratio > 100 { // Massive reduction (>100x pixels)
|
|
resizeMethod = resize.NearestNeighbor
|
|
log.Printf("Using NearestNeighbor (ratio: %.1f)", ratio)
|
|
} else if ratio > 25 {
|
|
resizeMethod = resize.Bilinear
|
|
log.Printf("Using Bilinear (ratio: %.1f)", ratio)
|
|
} else {
|
|
resizeMethod = resize.Lanczos3
|
|
log.Printf("Using Lanczos3 (ratio: %.1f)", ratio)
|
|
}
|
|
|
|
// For VERY large images, do multi-pass resize
|
|
if ratio > 50 {
|
|
// First pass: reduce to intermediate size
|
|
intermediateSize := maxDimension * 3
|
|
var iWidth, iHeight int
|
|
if width > height {
|
|
iWidth = intermediateSize
|
|
iHeight = int(float64(height) * float64(intermediateSize) / float64(width))
|
|
} else {
|
|
iHeight = intermediateSize
|
|
iWidth = int(float64(width) * float64(intermediateSize) / float64(height))
|
|
}
|
|
|
|
log.Printf("Multi-pass resize: %dx%d -> %dx%d -> %dx%d",
|
|
width, height, iWidth, iHeight, newWidth, newHeight)
|
|
|
|
// First pass
|
|
tempImg := resize.Resize(uint(iWidth), uint(iHeight), img, resize.NearestNeighbor)
|
|
img = nil
|
|
runtime.GC()
|
|
|
|
// Second pass
|
|
resized := resize.Resize(uint(newWidth), uint(newHeight), tempImg, resize.Lanczos3)
|
|
tempImg = nil
|
|
runtime.GC()
|
|
|
|
// Save
|
|
return saveJPEG(resized, outputPath)
|
|
}
|
|
|
|
// Single pass for smaller reductions
|
|
resized := resize.Resize(uint(newWidth), uint(newHeight), img, resizeMethod)
|
|
img = nil
|
|
runtime.GC()
|
|
|
|
return saveJPEG(resized, outputPath)
|
|
}
|
|
|
|
// Helper function to save JPEG and free memory
|
|
func saveJPEG(img image.Image, path string) error {
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
img = nil
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
// Lower quality = smaller memory footprint during encoding
|
|
err = jpeg.Encode(out, img, &jpeg.Options{Quality: 70})
|
|
img = nil
|
|
|
|
runtime.GC()
|
|
debug.FreeOSMemory()
|
|
|
|
return err
|
|
}
|
|
|
|
func handleTags(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
tagsMutex.RLock()
|
|
tagList := make([]Tag, 0, len(tags))
|
|
for _, tag := range tags {
|
|
tagList = append(tagList, tag)
|
|
}
|
|
tagsMutex.RUnlock()
|
|
|
|
sort.Slice(tagList, func(i, j int) bool {
|
|
return tagList[i].Name < tagList[j].Name
|
|
})
|
|
|
|
json.NewEncoder(w).Encode(tagList)
|
|
|
|
case http.MethodPost:
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
http.Error(w, "Tag name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Color == "" {
|
|
req.Color = "#1f6feb"
|
|
}
|
|
|
|
tagsMutex.Lock()
|
|
tags[req.Name] = Tag{
|
|
Name: req.Name,
|
|
Color: req.Color,
|
|
Count: 0,
|
|
}
|
|
tagsMutex.Unlock()
|
|
|
|
debounceSave()
|
|
json.NewEncoder(w).Encode(tags[req.Name])
|
|
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handleComicTags(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/comic-tags/"), "/")
|
|
if len(parts) == 0 {
|
|
http.Error(w, "Comic ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
id := parts[0]
|
|
decodedID, err := url.QueryUnescape(id)
|
|
if err != nil {
|
|
decodedID = id
|
|
}
|
|
|
|
comicsMutex.Lock()
|
|
defer comicsMutex.Unlock()
|
|
|
|
comic, exists := comics[decodedID]
|
|
if !exists {
|
|
comic, exists = comics[id]
|
|
if !exists {
|
|
http.Error(w, "Comic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
var req struct {
|
|
Tag string `json:"tag"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
found := false
|
|
for _, t := range comic.Tags {
|
|
if t == req.Tag {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
comic.Tags = append(comic.Tags, req.Tag)
|
|
comics[decodedID] = comic
|
|
updateTagCount(req.Tag, 1)
|
|
debounceSave()
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(comic)
|
|
|
|
case http.MethodDelete:
|
|
if len(parts) < 2 {
|
|
http.Error(w, "Tag required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tagToRemove, _ := url.QueryUnescape(parts[1])
|
|
newTags := []string{}
|
|
removed := false
|
|
|
|
for _, t := range comic.Tags {
|
|
if t != tagToRemove {
|
|
newTags = append(newTags, t)
|
|
} else {
|
|
removed = true
|
|
}
|
|
}
|
|
|
|
if removed {
|
|
comic.Tags = newTags
|
|
comics[decodedID] = comic
|
|
updateTagCount(tagToRemove, -1)
|
|
debounceSave()
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(comic)
|
|
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handleTryKnownPasswords(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/try-passwords/")
|
|
decodedID, err := url.QueryUnescape(id)
|
|
if err != nil {
|
|
decodedID = id
|
|
}
|
|
|
|
comicsMutex.RLock()
|
|
comic, exists := comics[decodedID]
|
|
comicsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "Comic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if !comic.Encrypted {
|
|
http.Error(w, "Comic not encrypted", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get all known passwords
|
|
passwordsMutex.RLock()
|
|
knownPasswords := make([]string, 0, len(comicPasswords))
|
|
for _, pwd := range comicPasswords {
|
|
found := false
|
|
for _, existing := range knownPasswords {
|
|
if existing == pwd {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
knownPasswords = append(knownPasswords, pwd)
|
|
}
|
|
}
|
|
passwordsMutex.RUnlock()
|
|
|
|
if len(knownPasswords) == 0 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": false,
|
|
"message": "No known passwords to try",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Try each password using the new validation function
|
|
validPassword := ""
|
|
for _, pwd := range knownPasswords {
|
|
if validatePassword(comic.FilePath, pwd) {
|
|
validPassword = pwd
|
|
break
|
|
}
|
|
}
|
|
|
|
if validPassword == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": false,
|
|
"message": "None of the known passwords worked",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Password worked! Save it and extract metadata
|
|
comicsMutex.Lock()
|
|
c := comics[decodedID]
|
|
c.Password = validPassword
|
|
c.HasPassword = true
|
|
extractMetadata(&c)
|
|
|
|
tagsMutex.Lock()
|
|
for _, tag := range c.Tags {
|
|
if tagData, exists := tags[tag]; exists {
|
|
tagData.Count++
|
|
tags[tag] = tagData
|
|
} else {
|
|
tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1}
|
|
}
|
|
}
|
|
tagsMutex.Unlock()
|
|
|
|
if c.Artist != "Unknown" || c.StoryArc != "" {
|
|
inker := sanitizeFilename(c.Artist)
|
|
storyArc := sanitizeFilename(c.StoryArc)
|
|
if inker == "" {
|
|
inker = "Unknown"
|
|
}
|
|
if storyArc == "" {
|
|
storyArc = "No_StoryArc"
|
|
}
|
|
newDir := filepath.Join(libraryPath, inker, storyArc)
|
|
os.MkdirAll(newDir, 0755)
|
|
filename := filepath.Base(c.FilePath)
|
|
newPath := filepath.Join(newDir, filename)
|
|
if newPath != c.FilePath {
|
|
if err := os.Rename(c.FilePath, newPath); err == nil {
|
|
c.FilePath = newPath
|
|
}
|
|
}
|
|
}
|
|
|
|
comics[decodedID] = c
|
|
comicsMutex.Unlock()
|
|
|
|
passwordsMutex.Lock()
|
|
comicPasswords[decodedID] = validPassword
|
|
passwordsMutex.Unlock()
|
|
|
|
debounceSave()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"message": "Password found and applied",
|
|
"comic": c,
|
|
})
|
|
}
|
|
|
|
func handleSetPassword(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/set-password/")
|
|
decodedID, err := url.QueryUnescape(id)
|
|
if err != nil {
|
|
decodedID = id
|
|
}
|
|
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
comicsMutex.Lock()
|
|
comic, exists := comics[decodedID]
|
|
if !exists {
|
|
comic, exists = comics[id]
|
|
}
|
|
comicsMutex.Unlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "Comic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if !comic.Encrypted {
|
|
http.Error(w, "Comic not encrypted", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Use the validation function
|
|
if !validatePassword(comic.FilePath, req.Password) {
|
|
http.Error(w, "Invalid password", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Password is valid - save it
|
|
comicsMutex.Lock()
|
|
c := comics[decodedID]
|
|
c.Password = req.Password
|
|
c.HasPassword = true
|
|
comics[decodedID] = c
|
|
comicsMutex.Unlock()
|
|
|
|
passwordsMutex.Lock()
|
|
comicPasswords[decodedID] = req.Password
|
|
passwordsMutex.Unlock()
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Update tags
|
|
tagsMutex.Lock()
|
|
for _, tag := range c.Tags {
|
|
if tagData, exists := tags[tag]; exists {
|
|
tagData.Count++
|
|
tags[tag] = tagData
|
|
} else {
|
|
tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1}
|
|
}
|
|
}
|
|
tagsMutex.Unlock()
|
|
|
|
// Auto-organize if we have metadata
|
|
if c.Artist != "Unknown" || c.StoryArc != "" {
|
|
inker := sanitizeFilename(c.Artist)
|
|
storyArc := sanitizeFilename(c.StoryArc)
|
|
if inker == "" {
|
|
inker = "Unknown"
|
|
}
|
|
if storyArc == "" {
|
|
storyArc = "No_StoryArc"
|
|
}
|
|
newDir := filepath.Join(libraryPath, inker, storyArc)
|
|
os.MkdirAll(newDir, 0755)
|
|
filename := filepath.Base(c.FilePath)
|
|
newPath := filepath.Join(newDir, filename)
|
|
if newPath != c.FilePath {
|
|
if err := os.Rename(c.FilePath, newPath); err == nil {
|
|
c.FilePath = newPath
|
|
}
|
|
}
|
|
}
|
|
|
|
comics[decodedID] = c
|
|
comicsMutex.Unlock()
|
|
|
|
debounceSave()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Password set successfully",
|
|
"comic": c,
|
|
})
|
|
}
|
|
|
|
func validatePassword(filePath string, password string) bool {
|
|
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]), "<?xml") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Try first image
|
|
for _, f := range yr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(f.Name))
|
|
isImage := ext == ".png" || ext == ".jpg" || ext == ".jpeg" ||
|
|
ext == ".gif" || ext == ".avif" || ext == ".jxl" ||
|
|
ext == ".webp" || ext == ".bmp" || ext == ".jp2"
|
|
|
|
if !isImage {
|
|
continue
|
|
}
|
|
|
|
if !f.IsEncrypted() {
|
|
return true
|
|
}
|
|
|
|
f.SetPassword(password)
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
_, _, err = image.DecodeConfig(rc)
|
|
rc.Close()
|
|
|
|
return err == nil
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func handleBookmark(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/bookmark/"), "/")
|
|
if len(parts) == 0 {
|
|
http.Error(w, "Comic ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
id := parts[0]
|
|
decodedID, err := url.QueryUnescape(id)
|
|
if err != nil {
|
|
decodedID = id
|
|
}
|
|
|
|
comicsMutex.Lock()
|
|
defer comicsMutex.Unlock()
|
|
|
|
comic, exists := comics[decodedID]
|
|
if !exists {
|
|
comic, exists = comics[id]
|
|
if !exists {
|
|
http.Error(w, "Comic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
var req struct {
|
|
Page int `json:"page"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if comic.Bookmarks == nil {
|
|
comic.Bookmarks = []int{}
|
|
}
|
|
|
|
found := false
|
|
for _, p := range comic.Bookmarks {
|
|
if p == req.Page {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
comic.Bookmarks = append(comic.Bookmarks, req.Page)
|
|
sort.Ints(comic.Bookmarks)
|
|
}
|
|
|
|
comics[decodedID] = comic
|
|
debounceSave()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Bookmark added",
|
|
"bookmarks": comic.Bookmarks,
|
|
})
|
|
|
|
case http.MethodDelete:
|
|
if len(parts) < 2 {
|
|
http.Error(w, "Page number required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var pageNum int
|
|
fmt.Sscanf(parts[1], "%d", &pageNum)
|
|
|
|
if comic.Bookmarks == nil {
|
|
comic.Bookmarks = []int{}
|
|
}
|
|
|
|
newBookmarks := []int{}
|
|
for _, p := range comic.Bookmarks {
|
|
if p != pageNum {
|
|
newBookmarks = append(newBookmarks, p)
|
|
}
|
|
}
|
|
|
|
comic.Bookmarks = newBookmarks
|
|
comics[decodedID] = comic
|
|
debounceSave()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Bookmark removed",
|
|
"bookmarks": comic.Bookmarks,
|
|
})
|
|
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handleComicFile(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/comic/"), "/")
|
|
id := parts[0]
|
|
|
|
decodedID, err := url.QueryUnescape(id)
|
|
if err != nil {
|
|
decodedID = id
|
|
}
|
|
|
|
comicsMutex.RLock()
|
|
comic, exists := comics[decodedID]
|
|
if !exists {
|
|
comic, exists = comics[id]
|
|
}
|
|
comicsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "Comic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if len(parts) > 1 && parts[1] == "page" && len(parts) > 2 {
|
|
pageNum := parts[2]
|
|
serveComicPage(w, r, comic, pageNum)
|
|
return
|
|
}
|
|
|
|
http.ServeFile(w, r, comic.FilePath)
|
|
}
|
|
|
|
func serveComicPage(w http.ResponseWriter, r *http.Request, comic Comic, pageNum string) {
|
|
// Handle CBT files
|
|
if comic.FileType == ".cbt" {
|
|
serveCBTPage(w, r, comic, pageNum)
|
|
return
|
|
}
|
|
|
|
// Handle CBZ files
|
|
if comic.FileType != ".cbz" {
|
|
http.Error(w, "Only CBZ and CBT formats supported for page viewing", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get password from memory if needed
|
|
passwordsMutex.RLock()
|
|
password, hasPassword := comicPasswords[comic.ID]
|
|
passwordsMutex.RUnlock()
|
|
|
|
if comic.Encrypted && !hasPassword {
|
|
http.Error(w, "Password required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var pageIdx int
|
|
fmt.Sscanf(pageNum, "%d", &pageIdx)
|
|
|
|
yr, err := yzip.OpenReader(comic.FilePath)
|
|
if err != nil {
|
|
http.Error(w, "Error reading comic", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer yr.Close()
|
|
|
|
var imageFiles []*yzip.File
|
|
for _, f := range yr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(f.Name))
|
|
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".avif" ||
|
|
ext == ".jxl" || ext == ".jp2" || ext == ".webp" || ext == ".gif" || ext == ".bmp" {
|
|
imageFiles = append(imageFiles, f)
|
|
}
|
|
}
|
|
|
|
sort.Slice(imageFiles, func(i, j int) bool {
|
|
return imageFiles[i].Name < imageFiles[j].Name
|
|
})
|
|
|
|
if pageIdx < 0 || pageIdx >= len(imageFiles) {
|
|
http.Error(w, "Page not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
targetFile := imageFiles[pageIdx]
|
|
|
|
if targetFile.IsEncrypted() {
|
|
if hasPassword {
|
|
targetFile.SetPassword(password)
|
|
} else {
|
|
http.Error(w, "Comic requires password", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
|
|
rc, err := targetFile.Open()
|
|
if err != nil {
|
|
http.Error(w, "Error reading page", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rc.Close()
|
|
|
|
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")
|
|
|
|
buf := make([]byte, 32*1024)
|
|
_, err = io.CopyBuffer(w, rc, buf)
|
|
if err != nil {
|
|
log.Printf("Error streaming page to client: %v", err)
|
|
}
|
|
}
|
|
|
|
func handleComicPages(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/pages/")
|
|
decodedID, err := url.QueryUnescape(id)
|
|
if err != nil {
|
|
decodedID = id
|
|
}
|
|
|
|
comicsMutex.RLock()
|
|
comic, exists := comics[decodedID]
|
|
if !exists {
|
|
comic, exists = comics[id]
|
|
}
|
|
comicsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "Comic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Check if we have password
|
|
passwordsMutex.RLock()
|
|
password, hasPassword := comicPasswords[comic.ID]
|
|
passwordsMutex.RUnlock()
|
|
|
|
if comic.Encrypted && !hasPassword {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"needs_password": true,
|
|
"page_count": 0,
|
|
"pages": []string{},
|
|
})
|
|
return
|
|
}
|
|
|
|
// Load metadata on first access (if not already loaded)
|
|
if comic.Series == "" && comic.Title == "" {
|
|
loadComicMetadataLazy(comic.ID)
|
|
comicsMutex.RLock()
|
|
comic = comics[comic.ID]
|
|
comicsMutex.RUnlock()
|
|
}
|
|
|
|
// Handle CBT files
|
|
if comic.FileType == ".cbt" {
|
|
count, err := getCBTPageCount(comic)
|
|
if err != nil {
|
|
http.Error(w, "Error reading comic", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"page_count": count,
|
|
"pages": []string{},
|
|
})
|
|
return
|
|
}
|
|
|
|
// Handle CBZ files
|
|
if comic.FileType != ".cbz" {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"page_count": 0,
|
|
"pages": []string{},
|
|
})
|
|
return
|
|
}
|
|
|
|
yr, err := yzip.OpenReader(comic.FilePath)
|
|
if err != nil {
|
|
http.Error(w, "Error reading comic", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer yr.Close()
|
|
|
|
var imageFiles []string
|
|
for _, f := range yr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(f.Name))
|
|
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".avif" ||
|
|
ext == ".jxl" || ext == ".jp2" || ext == ".webp" || ext == ".gif" || ext == ".bmp" {
|
|
|
|
// Set password if encrypted
|
|
if f.IsEncrypted() && hasPassword {
|
|
f.SetPassword(password)
|
|
}
|
|
|
|
imageFiles = append(imageFiles, f.Name)
|
|
}
|
|
}
|
|
|
|
sort.Strings(imageFiles)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"page_count": len(imageFiles),
|
|
"pages": imageFiles,
|
|
})
|
|
}
|
|
|
|
func processComic(filePath, filename string, modTime time.Time) Comic {
|
|
comic := Comic{
|
|
ID: generateToken(),
|
|
Filename: filename,
|
|
FilePath: filePath,
|
|
FileType: strings.ToLower(filepath.Ext(filename)),
|
|
UploadedAt: time.Now(),
|
|
Artist: "Unknown",
|
|
Tags: []string{},
|
|
Bookmarks: []int{},
|
|
LastModified: modTime,
|
|
Encrypted: false,
|
|
HasPassword: false,
|
|
}
|
|
|
|
// Check if encrypted based on file type
|
|
if comic.FileType == ".cbz" {
|
|
yr, err := yzip.OpenReader(comic.FilePath)
|
|
if err == nil {
|
|
for _, f := range yr.File {
|
|
if f.IsEncrypted() {
|
|
comic.Encrypted = true
|
|
break
|
|
}
|
|
}
|
|
yr.Close()
|
|
}
|
|
} else if comic.FileType == ".cbt" {
|
|
encrypted, err := isTarEncrypted(comic.FilePath)
|
|
if err == nil {
|
|
comic.Encrypted = encrypted
|
|
}
|
|
|
|
// IMPORTANT: Extract metadata for unencrypted CBT files
|
|
if !encrypted {
|
|
extractCBTMetadata(&comic)
|
|
|
|
// Update tag counts for newly discovered tags
|
|
tagsMutex.Lock()
|
|
for _, tag := range comic.Tags {
|
|
if tagData, exists := tags[tag]; exists {
|
|
tagData.Count++
|
|
tags[tag] = tagData
|
|
} else {
|
|
tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1}
|
|
}
|
|
}
|
|
tagsMutex.Unlock()
|
|
}
|
|
}
|
|
|
|
// Extract artist from directory structure
|
|
parentDir := filepath.Dir(filePath)
|
|
if filepath.Base(parentDir) != "Unorganized" {
|
|
dirName := filepath.Base(filepath.Dir(parentDir))
|
|
// Only override if metadata didn't provide an artist
|
|
if comic.Artist == "Unknown" {
|
|
comic.Artist = dirName
|
|
}
|
|
}
|
|
|
|
comic.CoverImage = "/api/cover/" + url.QueryEscape(comic.ID)
|
|
|
|
return comic
|
|
}
|
|
|
|
|
|
func loadComicMetadataLazy(comicID string) error {
|
|
comicsMutex.Lock()
|
|
defer comicsMutex.Unlock()
|
|
|
|
comic, exists := comics[comicID]
|
|
if !exists {
|
|
return fmt.Errorf("comic not found")
|
|
}
|
|
|
|
// Already has metadata, skip
|
|
if comic.Series != "" || comic.Title != "" {
|
|
return nil
|
|
}
|
|
|
|
// Check if we have a password
|
|
passwordsMutex.RLock()
|
|
password, hasPassword := comicPasswords[comic.ID]
|
|
passwordsMutex.RUnlock()
|
|
|
|
if comic.Encrypted && !hasPassword {
|
|
return fmt.Errorf("password required")
|
|
}
|
|
|
|
// Set password if we have it
|
|
if hasPassword {
|
|
comic.Password = password
|
|
comic.HasPassword = true
|
|
}
|
|
|
|
// Extract metadata NOW (works for both CBZ and CBT)
|
|
extractMetadata(&comic)
|
|
|
|
// Update tags
|
|
tagsMutex.Lock()
|
|
for _, tag := range comic.Tags {
|
|
if tagData, exists := tags[tag]; exists {
|
|
tagData.Count++
|
|
tags[tag] = tagData
|
|
} else {
|
|
tags[tag] = Tag{Name: tag, Color: "#1f6feb", Count: 1}
|
|
}
|
|
}
|
|
tagsMutex.Unlock()
|
|
|
|
// Auto-organize if we have metadata
|
|
if comic.Artist != "Unknown" || comic.StoryArc != "" {
|
|
inker := sanitizeFilename(comic.Artist)
|
|
storyArc := sanitizeFilename(comic.StoryArc)
|
|
if inker == "" {
|
|
inker = "Unknown"
|
|
}
|
|
if storyArc == "" {
|
|
storyArc = "No_StoryArc"
|
|
}
|
|
newDir := filepath.Join(libraryPath, inker, storyArc)
|
|
os.MkdirAll(newDir, 0755)
|
|
filename := filepath.Base(comic.FilePath)
|
|
newPath := filepath.Join(newDir, filename)
|
|
if newPath != comic.FilePath {
|
|
if err := os.Rename(comic.FilePath, newPath); err == nil {
|
|
comic.FilePath = newPath
|
|
}
|
|
}
|
|
}
|
|
|
|
comics[comicID] = comic
|
|
debounceSave()
|
|
|
|
return nil
|
|
}
|
|
|
|
func extractMetadata(comic *Comic) {
|
|
if comic.FileType == ".cbz" {
|
|
extractCBZMetadataInternal(comic)
|
|
} else if comic.FileType == ".cbt" {
|
|
extractCBTMetadata(comic)
|
|
}
|
|
}
|
|
|
|
func extractCBZMetadataInternal(comic *Comic) {
|
|
if comic.FileType != ".cbz" {
|
|
return
|
|
}
|
|
|
|
yr, err := yzip.OpenReader(comic.FilePath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer yr.Close()
|
|
|
|
for _, f := range yr.File {
|
|
if strings.ToLower(f.Name) != "comicinfo.xml" {
|
|
continue
|
|
}
|
|
|
|
if f.IsEncrypted() && comic.Password != "" {
|
|
f.SetPassword(comic.Password)
|
|
}
|
|
|
|
tmp, err := os.CreateTemp("", "comic-metadata-*.xml")
|
|
if err != nil {
|
|
return
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
defer tmp.Close()
|
|
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = io.Copy(tmp, rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
tmp.Seek(0, 0)
|
|
var info ComicInfo
|
|
if err := xml.NewDecoder(tmp).Decode(&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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func scanLibrary() {
|
|
comicsMutex.RLock()
|
|
existingPaths := make(map[string]string)
|
|
for id, comic := range comics {
|
|
existingPaths[comic.FilePath] = id
|
|
}
|
|
comicsMutex.RUnlock()
|
|
|
|
filepath.Walk(libraryPath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if ext != ".cbz" && ext != ".cbt" {
|
|
return nil
|
|
}
|
|
|
|
comicsMutex.RLock()
|
|
id, exists := existingPaths[path]
|
|
var currentMTime time.Time
|
|
if exists {
|
|
currentMTime = comics[id].LastModified
|
|
}
|
|
comicsMutex.RUnlock()
|
|
|
|
// Skip if unchanged
|
|
if exists && currentMTime.Equal(info.ModTime()) {
|
|
return nil
|
|
}
|
|
|
|
// Modified or new file
|
|
if exists {
|
|
comicsMutex.Lock()
|
|
c := comics[id]
|
|
c.LastModified = info.ModTime()
|
|
|
|
// Re-extract metadata if file changed
|
|
if ext == ".cbt" {
|
|
// Check if encrypted
|
|
encrypted, _ := isTarEncrypted(path)
|
|
if !encrypted {
|
|
extractCBTMetadata(&c)
|
|
}
|
|
}
|
|
|
|
comics[id] = c
|
|
comicsMutex.Unlock()
|
|
} else {
|
|
// New file - process it fully
|
|
comic := processComic(path, info.Name(), info.ModTime())
|
|
comicsMutex.Lock()
|
|
comics[comic.ID] = comic
|
|
comicsMutex.Unlock()
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
// Remove deleted files
|
|
comicsMutex.Lock()
|
|
for id, comic := range comics {
|
|
if _, err := os.Stat(comic.FilePath); os.IsNotExist(err) {
|
|
for _, tag := range comic.Tags {
|
|
updateTagCount(tag, -1)
|
|
}
|
|
delete(comics, id)
|
|
}
|
|
}
|
|
comicsMutex.Unlock()
|
|
|
|
debounceSave()
|
|
runtime.GC()
|
|
debug.FreeOSMemory()
|
|
}
|
|
|
|
|
|
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
sessionsMutex.RLock()
|
|
session, exists := sessions[cookie.Value]
|
|
sessionsMutex.RUnlock()
|
|
|
|
if !exists || time.Now().After(session.ExpiresAt) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
next(w, r)
|
|
}
|
|
}
|
|
func getUsersPath() string {
|
|
return filepath.Join(etcPath, "users.json")
|
|
}
|
|
|
|
func getAdminPath() string {
|
|
return filepath.Join(etcPath, "admin.json")
|
|
}
|
|
|
|
func loadUsers() {
|
|
data, err := os.ReadFile(getUsersPath())
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := json.Unmarshal(data, &users); err != nil {
|
|
log.Printf("Error unmarshaling users: %v", err)
|
|
}
|
|
|
|
adminData, err := os.ReadFile(getAdminPath())
|
|
if err == nil && len(adminData) > 0 {
|
|
var adminConfig struct{ RegistrationEnabled bool }
|
|
if err := json.Unmarshal(adminData, &adminConfig); err == nil {
|
|
registrationEnabled = adminConfig.RegistrationEnabled
|
|
}
|
|
}
|
|
}
|
|
|
|
func saveUsers() {
|
|
data, _ := json.MarshalIndent(users, "", " ")
|
|
os.WriteFile(getUsersPath(), data, 0644)
|
|
}
|
|
|
|
func saveAdminConfig() {
|
|
config := struct{ RegistrationEnabled bool }{RegistrationEnabled: registrationEnabled}
|
|
data, _ := json.MarshalIndent(config, "", " ")
|
|
os.WriteFile(getAdminPath(), data, 0644)
|
|
}
|
|
|
|
func loadTags() {
|
|
data, err := os.ReadFile(filepath.Join(libraryPath, "tags.json"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
tagsMutex.Lock()
|
|
defer tagsMutex.Unlock()
|
|
json.Unmarshal(data, &tags)
|
|
}
|
|
|
|
func saveTags() {
|
|
tagsMutex.RLock()
|
|
defer tagsMutex.RUnlock()
|
|
data, _ := json.MarshalIndent(tags, "", " ")
|
|
os.WriteFile(filepath.Join(libraryPath, "tags.json"), data, 0644)
|
|
}
|
|
|
|
func saveComics() {
|
|
comicsMutex.RLock()
|
|
defer comicsMutex.RUnlock()
|
|
data, _ := json.MarshalIndent(comics, "", " ")
|
|
os.WriteFile(filepath.Join(libraryPath, "comics.json"), data, 0644)
|
|
}
|
|
|
|
func loadComics() {
|
|
data, err := os.ReadFile(filepath.Join(libraryPath, "comics.json"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
comicsMutex.Lock()
|
|
defer comicsMutex.Unlock()
|
|
json.Unmarshal(data, &comics)
|
|
}
|
|
|
|
func loadPasswordsWithKey(key []byte) {
|
|
data, err := os.ReadFile(filepath.Join(libraryPath, "passwords.json"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
b64data := strings.TrimSpace(string(data))
|
|
encrypted, err := base64.StdEncoding.DecodeString(b64data)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
decrypted, err := decryptAES(encrypted, key)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
passwordsMutex.Lock()
|
|
defer passwordsMutex.Unlock()
|
|
if err := json.Unmarshal(decrypted, &comicPasswords); err != nil {
|
|
return
|
|
}
|
|
|
|
}
|
|
|
|
func savePasswords() {
|
|
if len(currentEncryptionKey) == 0 {
|
|
return
|
|
}
|
|
|
|
passwordsMutex.Lock()
|
|
defer passwordsMutex.Unlock()
|
|
data, err := json.MarshalIndent(comicPasswords, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
encrypted, err := encryptAES(data, currentEncryptionKey)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
b64 := base64.StdEncoding.EncodeToString(encrypted)
|
|
os.WriteFile(filepath.Join(libraryPath, "passwords.json"), []byte(b64), 0644)
|
|
}
|
|
|
|
// Debounced save for comics and tags
|
|
func debounceSave() {
|
|
if saveTimer != nil {
|
|
saveTimer.Stop()
|
|
}
|
|
saveTimer = time.AfterFunc(5*time.Second, func() {
|
|
saveComics()
|
|
saveTags()
|
|
savePasswords() // Also save passwords if needed
|
|
})
|
|
}
|
|
|
|
func updateTagCount(tagName string, delta int) {
|
|
tagsMutex.Lock()
|
|
defer tagsMutex.Unlock()
|
|
|
|
if tag, exists := tags[tagName]; exists {
|
|
tag.Count += delta
|
|
if tag.Count < 0 {
|
|
tag.Count = 0
|
|
}
|
|
tags[tagName] = tag
|
|
}
|
|
}
|
|
|
|
func generateToken() string {
|
|
hash := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
|
return base64.URLEncoding.EncodeToString(hash[:])
|
|
}
|
|
|
|
func sanitizeFilename(filename string) string {
|
|
filename = strings.ReplaceAll(filename, " ", "_")
|
|
reg, _ := regexp.Compile("[^a-zA-Z0-9-_]+")
|
|
sanitized := reg.ReplaceAllString(filename, "_")
|
|
sanitized = strings.Trim(sanitized, "_")
|
|
if sanitized == "" {
|
|
return "Unknown"
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
func getContentType(ext string) string {
|
|
switch ext {
|
|
case ".png":
|
|
return "image/png"
|
|
case ".webp":
|
|
return "image/webp"
|
|
case ".avif":
|
|
return "image/avif"
|
|
case ".jxl":
|
|
return "image/jxl"
|
|
case ".jp2":
|
|
return "image/jp2"
|
|
case ".gif":
|
|
return "image/gif"
|
|
case ".bmp":
|
|
return "image/bmp"
|
|
default:
|
|
return "image/jpeg"
|
|
}
|
|
}
|
|
|
|
func deriveKey(seed string) []byte {
|
|
hash := sha256.Sum256([]byte(seed))
|
|
return hash[:32]
|
|
}
|
|
|
|
func decryptAES(data []byte, key []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(data) < aes.BlockSize {
|
|
return nil, fmt.Errorf("ciphertext too short")
|
|
}
|
|
|
|
iv := data[:aes.BlockSize]
|
|
ciphertext := data[aes.BlockSize:]
|
|
|
|
stream := cipher.NewCFBDecrypter(block, iv)
|
|
|
|
// FIX: Allocate a separate slice for plaintext so the
|
|
// original 'data' slice can be garbage collected.
|
|
plaintext := make([]byte, len(ciphertext))
|
|
stream.XORKeyStream(plaintext, ciphertext)
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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)
|
|
|
|
return append(iv, ciphertext...), nil
|
|
}
|
|
|
|
func serveUI(w http.ResponseWriter, r *http.Request) {
|
|
data, err := templateFS.ReadFile("templates/index.html")
|
|
if err != nil {
|
|
http.Error(w, "Template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write(data)
|
|
}
|
|
|
|
// Cleanup old sessions periodically
|
|
func cleanupSessions() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
for range ticker.C {
|
|
sessionsMutex.Lock()
|
|
for token, session := range sessions {
|
|
if time.Now().After(session.ExpiresAt) {
|
|
delete(sessions, token)
|
|
}
|
|
}
|
|
sessionsMutex.Unlock()
|
|
}
|
|
}
|