428 lines
13 KiB
Go
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)
|
|
}
|
|
}
|