Files
akiyama-manga/internal/handlers/handlers.go
T

668 lines
18 KiB
Go

package handlers
import (
"context"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yourusername/akiyama-manga/internal/auth"
"github.com/yourusername/akiyama-manga/internal/models"
"github.com/yourusername/akiyama-manga/internal/services"
"github.com/yourusername/akiyama-manga/internal/storage"
"gorm.io/gorm"
)
// Home handler returns the home page
func Home(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "Akiyama Manga - Read and Share Manga",
})
}
// Reader handler returns the manga reader page
func Reader(c *gin.Context) {
c.HTML(http.StatusOK, "reader.html", gin.H{
"title": "Manga Reader - Akiyama Manga",
})
}
// GetMangas returns a list of mangas with pagination
func GetMangas(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
gormDB := db.(*gorm.DB)
page := c.DefaultQuery("page", "1")
pageNum, err := strconv.Atoi(page)
if err != nil || pageNum < 1 {
pageNum = 1
}
limit := 20
offset := (pageNum - 1) * limit
var mangas []models.Manga
var total int64
// Get total count
if err := gormDB.Model(&models.Manga{}).Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch manga list"})
return
}
// Get paginated mangas
if err := gormDB.
Preload("Chapters", func(db *gorm.DB) *gorm.DB {
return db.Order("number DESC").Limit(5)
}).
Order("created_at DESC").
Offset(offset).
Limit(limit).
Find(&mangas).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch mangas"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": mangas,
"pagination": gin.H{
"page": pageNum,
"limit": limit,
"total": total,
"total_pages": (total + int64(limit) - 1) / int64(limit),
},
})
}
}
// GetMangaDetail returns details of a specific manga
func GetMangaDetail(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
gormDB := db.(*gorm.DB)
mangaID := c.Param("id")
var manga models.Manga
if err := gormDB.
Preload("Chapters", func(db *gorm.DB) *gorm.DB {
return db.Order("number DESC")
}).
Preload("Chapters.Pages", func(db *gorm.DB) *gorm.DB {
return db.Order("page_num ASC")
}).
First(&manga, "id = ?", mangaID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "manga not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch manga"})
}
return
}
c.JSON(http.StatusOK, manga)
}
}
// GetChapterPages returns pages for a specific chapter
func GetChapterPages(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
}
}
// SearchMangas searches for mangas
func SearchMangas(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"results": []gin.H{},
})
}
}
// SignUp handles user registration
func SignUp(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
gormDB := db.(*gorm.DB)
var req struct {
DisplayName string `json:"displayName" binding:"required"`
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
// Parse request body
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if user already exists
var existingUser models.User
if err := gormDB.Where("email = ? OR username = ?", req.Email, req.Username).First(&existingUser).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Email or username already exists"})
return
} else if err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// Hash password
hashedPassword, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password hashing failed"})
return
}
// Create new user
newUser := models.User{
ID: uuid.New(),
Email: req.Email,
Username: req.Username,
Password: hashedPassword,
}
// Save to database
if err := gormDB.Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
// Generate JWT token
token, err := auth.GenerateToken(newUser.ID, newUser.Email, newUser.Username, newUser.IsAdmin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"})
return
}
// Set auth cookie
c.SetCookie("auth_token", token, 86400, "/", "", false, true)
c.JSON(http.StatusCreated, gin.H{
"token": token,
"user": gin.H{
"id": newUser.ID,
"email": newUser.Email,
"name": newUser.Username,
},
})
}
}
// SignIn handles user login
func SignIn(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
gormDB := db.(*gorm.DB)
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
// Parse request body
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Find user by email
var user models.User
if err := gormDB.Where("email = ?", req.Email).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Verify password
if !auth.CheckPassword(user.Password, req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Generate JWT token
token, err := auth.GenerateToken(user.ID, user.Email, user.Username, user.IsAdmin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"})
return
}
// Set auth cookie
c.SetCookie("auth_token", token, 86400, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Username,
},
})
}
}
// GetMe returns the current authenticated user
func GetMe(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from JWT claims (set by middleware)
userIDInterface, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found in context"})
return
}
// Convert UUID to string
var userID string
switch v := userIDInterface.(type) {
case string:
userID = v
case uuid.UUID:
userID = v.String()
default:
userID = fmt.Sprintf("%v", userIDInterface)
}
// Fetch user from database
var user models.User
if err := db.First(&user, "id = ?", userID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
}
return
}
// Return user info (excluding password)
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Username, // Return username as name for frontend
})
}
}
// Logout handles user logout
func Logout(c *gin.Context) {
// Clear the JWT cookie
c.SetCookie("auth_token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{
"message": "successfully logged out",
})
}
// GetUserLibrary returns user's manga library
func GetUserLibrary(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
}
}
// AddToLibrary adds a manga to user's library
func AddToLibrary(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{})
}
}
// RemoveFromLibrary removes a manga from user's library
func RemoveFromLibrary(db interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
}
}
// TrackReadingProgress tracks the user's reading progress
func TrackReadingProgress(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from context (should be set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req struct {
MangaID string `json:"manga_id" binding:"required"`
ChapterID string `json:"chapter_id"`
PageNum int `json:"page_num" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
// Parse UUIDs
mangaID, err := uuid.Parse(req.MangaID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manga_id"})
return
}
userUUID := userID.(uuid.UUID)
// Upsert reading progress
progress := models.ReadingProgress{
UserID: userUUID,
MangaID: mangaID,
PageNum: req.PageNum,
}
if req.ChapterID != "" {
chapterID, err := uuid.Parse(req.ChapterID)
if err == nil {
progress.ChapterID = chapterID
}
}
// Use FirstOrCreate to insert or update
if err := db.
Where("user_id = ? AND manga_id = ?", userUUID, mangaID).
Assign(progress).
FirstOrCreate(&progress).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reading progress"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "reading progress saved",
"progress": progress,
})
}
}
// UploadManga handles manga volume upload (create new series or add to existing)
func UploadManga(db *gorm.DB, s3Client *storage.S3Client) gin.HandlerFunc {
return func(c *gin.Context) {
// Parse form data
title := c.PostForm("title")
author := c.PostForm("author")
artist := c.PostForm("artist")
description := c.PostForm("description")
status := c.PostForm("status")
chapterNumStr := c.PostForm("chapter_number")
mangaIDStr := c.PostForm("manga_id") // Optional: for adding volumes to existing series
// Validate required fields
if chapterNumStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_number is required"})
return
}
// If no manga_id, then we're creating a new series - require title and author
if mangaIDStr == "" {
if title == "" || author == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "title and author are required for new series"})
return
}
}
// Parse chapter number
chapterNum, err := strconv.ParseFloat(chapterNumStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid chapter_number format"})
return
}
// Get files from request
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
// Determine manga ID: use existing or create new
var mangaID uuid.UUID
var manga *models.Manga
if mangaIDStr != "" {
// Add to existing manga series
parsedID, err := uuid.Parse(mangaIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manga_id format"})
return
}
// Verify manga exists
if err := db.First(&manga, "id = ?", parsedID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "manga not found"})
return
}
mangaID = parsedID
} else {
// Create new manga series
mangaID = uuid.New()
manga = &models.Manga{
ID: mangaID,
Title: title,
Author: author,
Artist: artist,
Description: description,
Status: status,
}
// Handle cover image upload for new series
coverFile, err := c.FormFile("cover")
if err == nil && coverFile != nil {
// Upload cover to S3
coverSrc, err := coverFile.Open()
if err == nil {
defer coverSrc.Close()
coverData := make([]byte, coverFile.Size)
if _, err := coverSrc.Read(coverData); err == nil {
// Extract file extension
ext := filepath.Ext(coverFile.Filename)
coverKey := fmt.Sprintf("assets/%s/cover%s", mangaID.String(), ext)
coverURL, _ := s3Client.UploadFile(context.Background(), coverKey, coverData, coverFile.Header.Get("Content-Type"))
manga.CoverImage = coverURL
}
}
}
// Save manga to database
if err := db.Create(manga).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create manga"})
return
}
}
// Process file and extract pages
processor := services.NewFileProcessor(s3Client, "/tmp/akiyama-manga-uploads")
pages, tempDir, err := processor.ProcessMangaFile(context.Background(), file)
if err != nil {
// Only delete manga if we created a new one
if mangaIDStr == "" {
db.Delete(manga)
}
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file processing error: %v", err)})
return
}
// Upload pages to S3
chapter, pageRecords, err := processor.UploadPagesToS3(context.Background(), mangaID, chapterNum, pages)
if err != nil {
// Cleanup temp directory
processor.CleanupTempDir(tempDir)
// Only delete manga if we created a new one
if mangaIDStr == "" {
db.Delete(manga)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("S3 upload error: %v", err)})
return
}
// Save chapter to database (explicitly omit Pages to prevent auto-save)
if err := db.Omit("Pages").Create(chapter).Error; err != nil {
// Cleanup temp directory
processor.CleanupTempDir(tempDir)
// Only delete manga if we created a new one
if mangaIDStr == "" {
db.Delete(manga)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save chapter to database"})
return
}
// Save pages to database
for _, page := range pageRecords {
if err := db.Create(&page).Error; err != nil {
// Cleanup on failure - delete in cascade order
processor.CleanupTempDir(tempDir)
// Delete all pages for this chapter
db.Where("chapter_id = ?", chapter.ID).Delete(&models.Page{})
// Delete chapter
db.Delete(chapter)
// Only delete manga if we created a new one
if mangaIDStr == "" {
db.Delete(manga)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pages to database"})
return
}
}
// Cleanup temporary directory after successful upload
if err := processor.CleanupTempDir(tempDir); err != nil {
// Log warning but don't fail the request
fmt.Printf("warning: failed to cleanup temp directory: %v\n", err)
}
// Return success response
c.JSON(http.StatusCreated, gin.H{
"message": "Manga uploaded successfully",
"manga_id": manga.ID,
"chapter": gin.H{
"id": chapter.ID,
"number": chapter.Number,
"page_count": chapter.PageCount,
},
})
}
}
// ExtractCBZMetadata extracts metadata from uploaded CBZ file
func ExtractCBZMetadata(s3Client *storage.S3Client) gin.HandlerFunc {
return func(c *gin.Context) {
// Get the uploaded file
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
// Only accept CBZ files
if !strings.HasSuffix(strings.ToLower(file.Filename), ".cbz") {
c.JSON(http.StatusBadRequest, gin.H{"error": "only CBZ files are supported for metadata extraction"})
return
}
// Extract metadata
processor := services.NewFileProcessor(s3Client, "/tmp/akiyama-manga-uploads")
metadata, err := processor.ExtractCBZMetadata(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to extract metadata: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"title": metadata.Title,
"author": metadata.Author,
"artist": metadata.Artist,
"description": metadata.Description,
"chapter": metadata.ChapterNum,
})
}
}
// UploadChapter handles chapter upload to S3
func UploadChapter(db *gorm.DB, s3Client *storage.S3Client) gin.HandlerFunc {
return func(c *gin.Context) {
mangaIDStr := c.Param("id")
chapterNumStr := c.PostForm("chapter_number")
// Parse manga ID
mangaID, err := uuid.Parse(mangaIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manga ID"})
return
}
// Parse chapter number
chapterNum, err := strconv.ParseFloat(chapterNumStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid chapter_number format"})
return
}
// Verify manga exists
var manga models.Manga
if err := db.First(&manga, "id = ?", mangaID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "manga not found"})
return
}
// Get file from request
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
// Process file and extract pages
processor := services.NewFileProcessor(s3Client, "/tmp/akiyama-manga-uploads")
pages, tempDir, err := processor.ProcessMangaFile(context.Background(), file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file processing error: %v", err)})
return
}
// Upload pages to S3
chapter, pageRecords, err := processor.UploadPagesToS3(context.Background(), mangaID, chapterNum, pages)
if err != nil {
processor.CleanupTempDir(tempDir)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("S3 upload error: %v", err)})
return
}
// Save chapter to database (explicitly omit Pages to prevent auto-save)
if err := db.Omit("Pages").Create(chapter).Error; err != nil {
processor.CleanupTempDir(tempDir)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save chapter to database"})
return
}
// Save pages to database
for _, page := range pageRecords {
if err := db.Create(&page).Error; err != nil {
processor.CleanupTempDir(tempDir)
db.Where("chapter_id = ?", chapter.ID).Delete(&models.Page{})
db.Delete(chapter)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pages to database"})
return
}
}
// Cleanup temporary directory
if err := processor.CleanupTempDir(tempDir); err != nil {
fmt.Printf("warning: failed to cleanup temp directory: %v\n", err)
}
// Return success response
c.JSON(http.StatusCreated, gin.H{
"message": "Chapter uploaded successfully",
"chapter": gin.H{
"id": chapter.ID,
"number": chapter.Number,
"page_count": chapter.PageCount,
},
})
}
}