428 lines
13 KiB
Go

package httpapi
import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"time"
"watch-party-backend/internal/auth"
"watch-party-backend/internal/core/episode"
"watch-party-backend/internal/danime"
"github.com/gin-gonic/gin"
)
func NewRouter(svc episode.UseCases, authClient auth.AuthClient, authEnabled bool) *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.GET("healthz", healthzHandler)
api := r.Group("/api")
v1 := api.Group("/v1")
v1.GET("/time", timeHandler)
v1.GET("/current", getCurrentHandler(svc))
v1.POST("/current", setCurrentHandler(svc))
v1.POST("/shows", createShowHandler(svc))
v1.GET("/shows", listShowsHandler(svc))
v1.POST("/oauth/firebase", oauthFirebaseHandler(authClient, authEnabled))
v1.POST("/oauth/firebase/claim-admin", claimFirebaseAdminHandler(authClient, authEnabled))
v1.GET("/danime", getDanimeHandler())
if authEnabled && authClient != nil {
protected := v1.Group("/")
protected.Use(AuthMiddleware(authClient))
protected.DELETE("/shows", deleteShowsHandler(svc))
protected.POST("/archive", moveToArchiveHandler(svc))
protected.GET("/archive", AdminOnly(), listArchiveHandler(svc))
} else {
v1.DELETE("/shows", deleteShowsHandler(svc))
v1.POST("/archive", moveToArchiveHandler(svc))
}
return r
}
// GET /healthz
func healthzHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// GET /v1/time
func timeHandler(c *gin.Context) {
now := time.Now().UTC().UnixMilli()
c.Header("Cache-Control", "no-store, max-age=0, must-revalidate")
c.JSON(http.StatusOK, gin.H{
"now": now,
})
}
// getCurrentHandler godoc
// @Summary Get current show
// @Description Returns the current row from `current` table.
// @Tags current
// @Produce json
// @Success 200 {object} CurrentResponse
// @Failure 404 {object} HTTPError "no current row found"
// @Failure 500 {object} HTTPError "query failed"
// @Router /api/v1/current [get]
func getCurrentHandler(svc episode.UseCases) gin.HandlerFunc {
return func(c *gin.Context) {
cur, err := svc.GetCurrent(c.Request.Context())
if err != nil {
if errors.Is(err, episode.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "no current row found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, cur)
}
}
// setCurrentHandler godoc
// @Summary Set current show
// @Description Set the current show ID and start time.
// @Tags current
// @Accept json
// @Produce json
// @Param body body SetCurrentReq true "Current show payload"
// @Success 200 {object} CurrentResponse
// @Failure 400 {object} HTTPError "invalid payload or invalid time"
// @Failure 404 {object} HTTPError "id not found"
// @Failure 500 {object} HTTPError "update failed"
// @Router /api/v1/current [post]
func setCurrentHandler(svc episode.UseCases) gin.HandlerFunc {
return func(c *gin.Context) {
var req SetCurrentReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
cur, err := svc.SetCurrent(c.Request.Context(), req.ID, req.StartTime)
if err != nil {
switch {
case errors.Is(err, episode.ErrInvalidStartTime):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
case errors.Is(err, episode.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "id not found"})
return
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
}
c.JSON(http.StatusOK, cur)
}
}
// createShowHandler godoc
// @Summary Create show
// @Description Insert a new show into `current`.
// @Tags shows
// @Accept json
// @Produce json
// @Param body body CreateShowReq true "New show payload"
// @Success 201 {object} CurrentResponse
// @Failure 400 {object} HTTPError "invalid payload or invalid time/duration"
// @Failure 500 {object} HTTPError "create failed"
// @Router /api/v1/shows [post]
func createShowHandler(svc episode.UseCases) gin.HandlerFunc {
return func(c *gin.Context) {
var req CreateShowReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
start := strings.TrimSpace(req.StartTime)
if start == "" {
start = "22:00:00"
}
in := episode.NewShowInput{
EpNum: req.EpNum,
EpTitle: req.EpTitle,
SeasonName: req.SeasonName,
StartTime: start,
PlaybackLength: req.PlaybackLength,
}
item, err := svc.Create(c.Request.Context(), in)
if err != nil {
switch {
case errors.Is(err, episode.ErrInvalidStartTime),
errors.Is(err, episode.ErrInvalidPlayback),
errors.Is(err, episode.ErrInvalidShowInput):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "create failed"})
return
}
}
c.JSON(http.StatusCreated, item)
}
}
var FetchDanimeEpisode = danime.FetchEpisode
// getDanimeHandler godoc
// @Summary Fetch dアニメ episode metadata
// @Description Fetch metadata from dアニメストア WS030101 by partId.
// @Tags scraper
// @Produce json
// @Param part_id query string true "dアニメ partId"
// @Success 200 {object} DanimeEpisodeResponse
// @Failure 400 {object} HTTPError "missing part_id"
// @Failure 502 {object} HTTPError "scrape failed"
// @Router /api/v1/danime [get]
func getDanimeHandler() gin.HandlerFunc {
return func(c *gin.Context) {
partID := strings.TrimSpace(c.Query("part_id"))
if partID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "part_id is required"})
return
}
ep, err := FetchDanimeEpisode(c.Request.Context(), partID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "scrape failed"})
return
}
c.JSON(http.StatusOK, DanimeEpisodeResponse{
EpNum: ep.EpNum,
EpTitle: ep.EpTitle,
SeasonName: ep.SeasonName,
PlaybackLength: ep.PlaybackLength,
})
}
}
// moveToArchiveHandler godoc
// @Summary Move shows to archive
// @Description Move one or many IDs from `current` to `current_archive`.
// @Tags archive
// @Accept json
// @Produce json
// @Param body body MoveReq true "IDs to move"
// @Success 200 {object} MoveRes
// @Failure 400 {object} HTTPError "empty ids"
// @Failure 500 {object} HTTPError "move failed"
// @Router /api/v1/archive [post]
func moveToArchiveHandler(svc episode.UseCases) gin.HandlerFunc {
return func(c *gin.Context) {
var req MoveReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
ids := make([]int64, 0, len(req.IDs)+1)
if req.ID != nil {
ids = append(ids, *req.ID)
}
ids = append(ids, req.IDs...)
res, err := svc.MoveToArchive(c.Request.Context(), ids)
if err != nil {
if errors.Is(err, episode.ErrEmptyIDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("archive move failed for ids=%v: %v", ids, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "move failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"moved_ids": res.MovedIDs,
"deleted_ids": res.DeletedIDs,
"skipped_ids": res.SkippedIDs,
"inserted": len(res.MovedIDs),
"deleted": len(res.DeletedIDs),
"skipped": len(res.SkippedIDs),
})
}
}
// listShowsHandler godoc
// @Summary List current shows
// @Description List all rows from `current` table.
// @Tags shows
// @Produce json
// @Success 200 {array} CurrentResponse
// @Failure 500 {object} HTTPError "list failed"
// @Router /api/v1/shows [get]
func listShowsHandler(svc episode.UseCases) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.ListAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"})
return
}
c.JSON(http.StatusOK, items)
}
}
// deleteShowHandler godoc
// @Summary Delete archived show
// @Description Delete a row from `current_archive` by ID.
// @Tags shows
// @Produce json
// @Param id query int64 true "Show ID"
// @Success 204 "No Content"
// @Failure 400 {object} HTTPError "invalid id"
// @Failure 404 {object} HTTPError "id not found"
// @Failure 500 {object} HTTPError "delete failed"
// @Router /api/v1/shows [delete]
func deleteShowsHandler(svc episode.UseCases) gin.HandlerFunc {
return func(c *gin.Context) {
idStr := c.Query("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
err = svc.Delete(c.Request.Context(), id)
if err != nil {
if errors.Is(err, episode.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "id not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.Status(http.StatusNoContent)
}
}
// oauthFirebaseHandler godoc
// @Summary Verify Firebase ID token
// @Description Validate Firebase ID token and return basic claims.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body FirebaseOAuthReq true "Firebase ID token"
// @Success 200 {object} FirebaseOAuthRes
// @Failure 400 {object} HTTPError "invalid payload"
// @Failure 401 {object} HTTPError "invalid token"
// @Failure 503 {object} HTTPError "auth disabled"
// @Router /api/v1/oauth/firebase [post]
func oauthFirebaseHandler(verifier auth.TokenVerifier, authEnabled bool) gin.HandlerFunc {
return func(c *gin.Context) {
if !authEnabled || verifier == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth disabled"})
return
}
var req FirebaseOAuthReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
idToken := strings.TrimSpace(req.IDToken)
if idToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id_token is required"})
return
}
token, err := verifier.Verify(c.Request.Context(), idToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
email, _ := token.Claims["email"].(string)
admin, _ := token.Claims["admin"].(bool)
c.JSON(http.StatusOK, FirebaseOAuthRes{
UID: token.UID,
Email: email,
Issuer: token.Issuer,
Expires: token.Expires,
Admin: admin,
CustomClaims: map[string]interface{}{
"admin": admin,
},
})
}
}
// claimFirebaseAdminHandler godoc
// @Summary Claim Firebase admin
// @Description Set the \"admin\" custom claim for the given Firebase UID.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body FirebaseAdminClaimReq true "Firebase UID"
// @Success 200 {object} FirebaseAdminClaimRes
// @Failure 400 {object} HTTPError "invalid payload"
// @Failure 503 {object} HTTPError "auth disabled"
// @Failure 500 {object} HTTPError "claim failed"
// @Router /api/v1/oauth/firebase/claim-admin [post]
func claimFirebaseAdminHandler(client auth.AuthClient, authEnabled bool) gin.HandlerFunc {
return func(c *gin.Context) {
if !authEnabled || client == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth disabled"})
return
}
var req FirebaseAdminClaimReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
uid := strings.TrimSpace(req.UID)
if uid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "uid is required"})
return
}
email, claims, err := client.SetAdminClaim(c.Request.Context(), uid)
if err != nil {
log.Printf("set admin claim failed for uid=%s: %v", uid, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"})
return
}
c.JSON(http.StatusOK, FirebaseAdminClaimRes{
UID: uid,
Email: email,
Admin: true,
CustomClaims: claims,
})
}
}
// listArchiveHandler godoc
// @Summary List archive
// @Description List all rows from `current_archive` (admin only).
// @Tags archive
// @Produce json
// @Success 200 {array} ArchiveResponse
// @Failure 403 {object} HTTPError "admin only"
// @Failure 500 {object} HTTPError "list failed"
// @Router /api/v1/archive [get]
func listArchiveHandler(svc episode.UseCases) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.ListArchive(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"})
return
}
res := make([]ArchiveResponse, 0, len(items))
for _, it := range items {
res = append(res, ArchiveResponse{
ID: it.Id,
EpNum: it.EpNum,
EpTitle: it.EpTitle,
SeasonName: it.SeasonName,
StartTime: it.StartTime,
PlaybackLength: it.PlaybackLength,
CurrentEp: it.CurrentEp,
DateCreated: it.DateCreated,
DateArchived: it.DateArchived,
})
}
c.JSON(http.StatusOK, res)
}
}