diff --git a/api/anime/handler.go b/api/anime/handler.go index da08ade..5876cf4 100644 --- a/api/anime/handler.go +++ b/api/anime/handler.go @@ -1,35 +1,17 @@ package anime import ( - "context" "encoding/json" - "errors" "html" "log" "net/http" "strconv" - "strings" "mal/integrations/jikan" "mal/internal/db" - animecomponents "mal/web/components/anime" - watchcomponents "mal/web/components/watch" - webcontext "mal/web/context" - "mal/web/templates" + "mal/templates" ) -func deduplicateAnimes(animes []jikan.Anime) []jikan.Anime { - seen := make(map[int]bool) - var result []jikan.Anime - for _, a := range animes { - if !seen[a.MalID] { - seen[a.MalID] = true - result = append(result, a) - } - } - return result -} - type Handler struct { jikanClient *jikan.Client db database.Querier @@ -44,7 +26,7 @@ type quickSearchResult struct { func renderNotFoundPage(r *http.Request, w http.ResponseWriter) { w.WriteHeader(http.StatusNotFound) - if err := templates.NotFoundPage().Render(r.Context(), w); err != nil { + if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } @@ -60,19 +42,9 @@ func parsePageParam(r *http.Request) int { if page < 1 { return 1 } - return page } -func userIDFromRequest(r *http.Request) string { - user, ok := r.Context().Value(webcontext.UserKey).(*database.User) - if !ok || user == nil { - return "" - } - - return user.ID -} - func NewHandler(jikanClient *jikan.Client, db database.Querier) *Handler { return &Handler{jikanClient: jikanClient, db: db} } @@ -82,312 +54,67 @@ func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) { renderNotFoundPage(r, w) return } - if err := templates.Catalog().Render(r.Context(), w); err != nil { + + animes, err := h.jikanClient.GetTopAnime(r.Context(), 1) + if err != nil { + log.Printf("top anime error: %v", err) + http.Error(w, "Failed to fetch anime", http.StatusInternalServerError) + return + } + + if len(animes.Animes) > 4 { + animes.Animes = animes.Animes[:4] + } + + if err := templates.GetRenderer().ExecuteTemplate(w, "index.gohtml", map[string]any{ + "Animes": animes.Animes, + }); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } -func (h *Handler) watchlistMap(ctx context.Context, userID string) map[int]string { - if userID == "" { - return nil - } - entries, err := h.db.GetUserWatchList(ctx, userID) - if err != nil { - return nil - } - m := make(map[int]string, len(entries)) - for _, e := range entries { - m[int(e.AnimeID)] = e.Status - } - return m -} - func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Vary", "HX-Request") - - query := r.URL.Query().Get("q") - if query == "" { - if err := templates.Search("").Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - return - } - - if r.Header.Get("HX-Request") == "true" { - res, err := h.jikanClient.Search(r.Context(), query, 1) - if err != nil { - log.Printf("search error: %v", err) - if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) { - writeInlineLoadError(w, "Search is temporarily unavailable. Please retry in a few seconds.") - return - } - http.Error(w, "Failed to search anime", http.StatusInternalServerError) - return - } - statuses := h.watchlistMap(r.Context(), userIDFromRequest(r)) - if err := templates.SearchResultsWrapper(query, res.Animes, statuses, 2, res.HasNextPage).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - return - } - - if err := templates.Search(query).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + renderNotFoundPage(r, w) } func (h *Handler) HandleAPISearch(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("q") - page := parsePageParam(r) - - res, err := h.jikanClient.Search(r.Context(), query, page) - if err != nil { - log.Printf("search pagination error: %v", err) - if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) { - writeInlineLoadError(w, "Unable to load more results right now. Please retry shortly.") - return - } - http.Error(w, "Failed to fetch search page", http.StatusInternalServerError) - return - } - - res.Animes = deduplicateAnimes(res.Animes) - - statuses := h.watchlistMap(r.Context(), userIDFromRequest(r)) - if err := templates.SearchItems(query, res.Animes, statuses, page+1, res.HasNextPage).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) { - page := parsePageParam(r) - - result, err := h.jikanClient.GetTopAnime(r.Context(), page) - if err == nil { - result.Animes = deduplicateAnimes(result.Animes) - statuses := h.watchlistMap(r.Context(), userIDFromRequest(r)) - if err := templates.CatalogItems(result.Animes, statuses, page+1, result.HasNextPage).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - return - } - - if jikan.IsRetryableError(err) { - if err := templates.CatalogError("Unable to load anime catalog").Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - return - } - - log.Printf("top anime error: %v", err) - http.Error(w, "Failed to fetch top anime", http.StatusInternalServerError) + http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) { - idStr := r.URL.Path[len("/anime/"):] - id, err := strconv.Atoi(idStr) - if err != nil || id <= 0 { - renderNotFoundPage(r, w) - return - } - - userID := userIDFromRequest(r) - - anime, err := h.jikanClient.GetAnimeByID(r.Context(), id) - if err != nil { - if jikan.IsNotFoundError(err) { - renderNotFoundPage(r, w) - return - } - - h.jikanClient.EnqueueAnimeFetchRetry(r.Context(), id, err) - if jikan.IsRetryableError(err) { - if err := animecomponents.Pending(id).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - return - } - - log.Printf("anime fetch error for %d: %v", id, err) - http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError) - return - } - - currentStatus := "" - nextEpisode := 1 - if userID != "" { - entry, err := h.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{ - UserID: userID, - AnimeID: int64(id), - }) - if err == nil { - currentStatus = entry.Status - if entry.CurrentEpisode.Valid { - value := int(entry.CurrentEpisode.Int64) - if value > 0 { - nextEpisode = value - } - } - } - } - - if err := templates.AnimeDetails(anime, currentStatus, nextEpisode).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + renderNotFoundPage(r, w) } func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path[len("/api/anime/"):] - - idPart, section, ok := strings.Cut(path, "/") - if !ok || section == "" { - http.Error(w, "invalid path", http.StatusBadRequest) - return - } - - id, err := strconv.Atoi(idPart) - if err != nil || id <= 0 { - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - - statuses := h.watchlistMap(r.Context(), userIDFromRequest(r)) - - w.Header().Set("Cache-Control", "public, max-age=3600") - - switch section { - case "relations": - relations, err := h.jikanClient.GetFullRelations(r.Context(), id) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return - } - log.Printf("relations error for %d: %v", id, err) - writeInlineLoadError(w, "Failed to load relations.") - return - } - if err := animecomponents.RelationsList(relations, statuses).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - case "recommendations": - recs, err := h.jikanClient.GetRecommendations(r.Context(), id, 12) - if err != nil { - log.Printf("recommendations error for %d: %v", id, err) - writeInlineLoadError(w, "Failed to load recommendations.") - return - } - if err := animecomponents.Recommendations(recs, statuses).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - case "episodes": - currentEpisode := r.URL.Query().Get("current") - episodes, err := h.getEpisodes(r.Context(), id) - if err != nil { - log.Printf("episodes error for %d: %v", id, err) - writeInlineLoadError(w, "Failed to load episodes.") - return - } - if err := watchcomponents.EpisodeList(episodes, currentEpisode, id).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - default: - renderNotFoundPage(r, w) - } + renderNotFoundPage(r, w) } func (h *Handler) HandleAPIEpisodes(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path[len("/api/episodes/"):] - id, err := strconv.Atoi(path) - if err != nil || id <= 0 { - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - - w.Header().Set("Cache-Control", "public, max-age=3600") - - currentEpisode := r.URL.Query().Get("current") - episodes, err := h.getEpisodes(r.Context(), id) - if err != nil { - log.Printf("episodes error: %v", err) - writeInlineLoadError(w, "Failed to load episodes.") - return - } - - if err := watchcomponents.EpisodeList(episodes, currentEpisode, id).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -func (h *Handler) getEpisodes(ctx context.Context, animeID int) ([]jikan.Episode, error) { - var allEpisodes []jikan.Episode - page := 1 - - for page <= 20 { - result, err := h.jikanClient.GetEpisodes(ctx, animeID, page) - if err != nil { - if jikan.IsRetryableError(err) && len(allEpisodes) > 0 { - return allEpisodes, nil - } - return nil, err - } - - allEpisodes = append(allEpisodes, result.Data...) - - if !result.Pagination.HasNextPage { - break - } - page++ - } - - return allEpisodes, nil + http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - query := r.URL.Query().Get("q") if query == "" { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode([]quickSearchResult{}) return } - res, err := h.jikanClient.SearchWithLimit(r.Context(), query, 1, 5) if err != nil { log.Printf("quick search error: %v", err) - if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) { - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode([]quickSearchResult{}) - return - } - w.WriteHeader(http.StatusInternalServerError) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]quickSearchResult{}) return } - - results := res.Animes - - output := make([]quickSearchResult, len(results)) - for i, anime := range results { + output := make([]quickSearchResult, len(res.Animes)) + for i, anime := range res.Animes { output[i] = quickSearchResult{ ID: anime.MalID, Title: anime.DisplayTitle(), @@ -395,132 +122,26 @@ func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { Image: anime.ImageURL(), } } - w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(output) } func (h *Handler) HandleDiscover(w http.ResponseWriter, r *http.Request) { - if err := templates.Discover().Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + renderNotFoundPage(r, w) } func (h *Handler) HandleAPIDiscoverAiring(w http.ResponseWriter, r *http.Request) { - page := parsePageParam(r) - - res, err := h.jikanClient.GetSeasonsNow(r.Context(), page) - if err != nil { - log.Printf("airing anime error: %v", err) - http.Error(w, "Failed to fetch airing anime", http.StatusInternalServerError) - return - } - - res.Animes = deduplicateAnimes(res.Animes) - - statuses := h.watchlistMap(r.Context(), userIDFromRequest(r)) - if err := templates.DiscoverItems(res.Animes, statuses, "airing", page+1, res.HasNextPage).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Request) { - page := parsePageParam(r) - - res, err := h.jikanClient.GetSeasonsUpcoming(r.Context(), page) - if err != nil { - log.Printf("upcoming anime error: %v", err) - http.Error(w, "Failed to fetch upcoming anime", http.StatusInternalServerError) - return - } - - res.Animes = deduplicateAnimes(res.Animes) - - statuses := h.watchlistMap(r.Context(), userIDFromRequest(r)) - if err := templates.DiscoverItems(res.Animes, statuses, "upcoming", page+1, res.HasNextPage).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleStudioDetails(w http.ResponseWriter, r *http.Request) { - idStr := r.URL.Path[len("/studios/"):] - id, err := strconv.Atoi(idStr) - if err != nil || id <= 0 { - renderNotFoundPage(r, w) - return - } - - producer, err := h.jikanClient.GetProducerByID(r.Context(), id) - if err != nil { - if jikan.IsNotFoundError(err) { - renderNotFoundPage(r, w) - return - } - - log.Printf("studio fetch error for %d: %v", id, err) - http.Error(w, "Failed to fetch studio details", http.StatusInternalServerError) - return - } - - result, err := h.jikanClient.GetAnimeByProducer(r.Context(), id, 1) - if err != nil { - log.Printf("studio anime fetch error for %d: %v", id, err) - if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) { - // Render page with empty anime list if API is rate limiting - if err := templates.StudioDetails(producer, []jikan.Anime{}, nil, false, 2).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - return - } - http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError) - return - } - - statuses := h.watchlistMap(r.Context(), userIDFromRequest(r)) - if err := templates.StudioDetails(producer, result.Animes, statuses, result.HasNextPage, 2).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + renderNotFoundPage(r, w) } func (h *Handler) HandleAPIStudioAnime(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path[len("/api/studios/"):] - - idPart, after, ok := strings.Cut(path, "/") - if !ok || after != "anime" { - http.Error(w, "invalid path", http.StatusBadRequest) - return - } - - id, err := strconv.Atoi(idPart) - if err != nil || id <= 0 { - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - - page := parsePageParam(r) - - result, err := h.jikanClient.GetAnimeByProducer(r.Context(), id, page) - if err != nil { - log.Printf("studio anime pagination error for %d page %d: %v", id, page, err) - if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) { - writeInlineLoadError(w, "Unable to load more results right now. Please retry shortly.") - return - } - http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError) - return - } - - result.Animes = deduplicateAnimes(result.Animes) - - statuses := h.watchlistMap(r.Context(), userIDFromRequest(r)) - if err := templates.StudioAnimeItems(result.Animes, statuses, result.HasNextPage, id, page+1).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + http.Error(w, "Not implemented yet", http.StatusNotImplemented) } diff --git a/api/auth/handler.go b/api/auth/handler.go index b44dc93..1f8c4b9 100644 --- a/api/auth/handler.go +++ b/api/auth/handler.go @@ -4,7 +4,7 @@ import ( "log" "net/http" - "mal/web/templates" + "mal/templates" ) type Handler struct { @@ -21,16 +21,26 @@ func rateLimitErrorFromQuery(r *http.Request) string { if r.URL.Query().Get("error") == "rate_limited" { return rateLimitFormError } - return "" } +func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) { + err := templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{ + "Error": rateLimitErrorFromQuery(r), + "Username": "", + }) + if err != nil { + log.Printf("render error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - if renderErr := templates.Login("Something went wrong. Please try again.", "").Render(r.Context(), w); renderErr != nil { - log.Printf("render error: %v", renderErr) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{ + "Error": "Something went wrong. Please try again.", + "Username": "", + }) return } @@ -38,34 +48,23 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { password := r.FormValue("password") if username == "" || password == "" { - if renderErr := templates.Login("The email or password is wrong.", username).Render(r.Context(), w); renderErr != nil { - log.Printf("render error: %v", renderErr) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{ + "Error": "The email or password is wrong.", + "Username": username, + }) return } session, err := h.authService.Login(r.Context(), username, password) if err != nil { - if renderErr := templates.Login("The email or password is wrong.", username).Render(r.Context(), w); renderErr != nil { - log.Printf("render error: %v", renderErr) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{ + "Error": "The email or password is wrong.", + "Username": username, + }) return } SetSessionCookie(w, session.ID, session.ExpiresAt) - if r.Header.Get("HX-Request") == "true" { - w.Header().Set("HX-Redirect", "/") - return - } - http.Redirect(w, r, "/", http.StatusFound) -} - -func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) { - if err := templates.Login(rateLimitErrorFromQuery(r), "").Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + http.Redirect(w, r, "/", http.StatusSeeOther) } diff --git a/api/playback/handler.go b/api/playback/handler.go index 15fe259..d6e9d02 100644 --- a/api/playback/handler.go +++ b/api/playback/handler.go @@ -1,26 +1,11 @@ package playback import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "io" "log" "net/http" - "net/url" - "strconv" - "strings" - "time" "mal/integrations/jikan" - "mal/internal/db" - "mal/internal/middleware" - "mal/web/components/watch" - webcontext "mal/web/context" - "mal/web/shared" - "mal/web/templates" + "mal/templates" ) type Handler struct { @@ -33,480 +18,23 @@ func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler { } func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - path := strings.TrimPrefix(r.URL.Path, "/watch/") - path = strings.Trim(path, "/") - if path == "" || strings.HasPrefix(path, "proxy/") { - http.NotFound(w, r) - return - } - - parts := strings.Split(path, "/") - if len(parts) < 1 { - http.NotFound(w, r) - return - } - - malID, err := strconv.Atoi(parts[0]) - if err != nil || malID <= 0 { - http.NotFound(w, r) - return - } - - // Get episode from path if provided, otherwise from query - episode := "" - if len(parts) >= 2 { - episode = strings.TrimSpace(parts[1]) - } - if episode == "" { - episode = strings.TrimSpace(r.URL.Query().Get("ep")) - } - if episode == "" { - episode = "1" - } - - mode := strings.TrimSpace(r.URL.Query().Get("mode")) - - ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) - defer cancel() - - // Fetch anime details - anime, err := h.jikanClient.GetAnimeByID(ctx, malID) - if err != nil { - log.Printf("failed to fetch anime %d: %v", malID, err) - http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError) - return - } - - if anime.Episodes > 0 { - episodeNumber, parseErr := strconv.Atoi(episode) - if parseErr == nil && episodeNumber > anime.Episodes { - http.Redirect(w, r, "/watch/"+strconv.Itoa(malID)+"/"+strconv.Itoa(anime.Episodes), http.StatusFound) - return - } - } - - titleCandidates := playbackTitleCandidates(anime) - userID := watchlistUserIDFromRequest(r) - data, err := h.svc.BuildWatchPageData(ctx, malID, titleCandidates, episode, mode, userID) - if err != nil { - log.Printf("watch page error for mal_id=%d: %v", malID, err) - http.Error(w, "Failed to load playback", http.StatusBadGateway) - return - } - - // Fetch episode title for the overlay - episodeTitle := "" - epNum, epErr := strconv.Atoi(episode) - if epErr == nil && epNum > 0 { - episodeData, epErr := h.jikanClient.GetEpisode(ctx, malID, epNum) - if epErr == nil && episodeData.Data.Title != "" { - episodeTitle = episodeData.Data.Title - } - } - - // Convert playback.WatchPageData to shared.WatchPageData - pageData := shared.WatchPageData{ - MalID: data.MalID, - Title: data.Title, - TitleEnglish: anime.TitleEnglish, - TitleJapanese: anime.TitleJapanese, - ImageURL: anime.ImageURL(), - Airing: anime.Airing, - CurrentEpisode: data.CurrentEpisode, - TotalEpisodes: anime.Episodes, - StartTimeSeconds: data.StartTimeSeconds, - CurrentStatus: data.CurrentStatus, - InitialMode: data.InitialMode, - AvailableModes: data.AvailableModes, - ModeSources: convertModeSources(data.ModeSources), - Segments: convertSegments(data.Segments), - EpisodeTitle: episodeTitle, - } - - if r.Header.Get("HX-Request") == "true" { - if err := watch.VideoPlayer(pageData, anime.DisplayTitle()).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - return - } - - if err := templates.WatchPage(anime, pageData).Render(r.Context(), w); err != nil { + if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil { log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } -func watchlistUserIDFromRequest(r *http.Request) string { - user, ok := r.Context().Value(webcontext.UserKey).(*database.User) - if !ok || user == nil { - return "" - } - - return user.ID -} - -func playbackTitleCandidates(anime jikan.Anime) []string { - out := make([]string, 0, 3+len(anime.TitleSynonyms)) - seen := make(map[string]struct{}) - - add := func(value string) { - normalized := strings.TrimSpace(value) - if normalized == "" { - return - } - - key := strings.ToLower(normalized) - if _, exists := seen[key]; exists { - return - } - - seen[key] = struct{}{} - out = append(out, normalized) - } - - add(anime.Title) - add(anime.TitleEnglish) - add(anime.TitleJapanese) - for _, synonym := range anime.TitleSynonyms { - add(synonym) - } - - return out -} - -func convertModeSources(sources map[string]ModeSource) map[string]shared.ModeSource { - result := make(map[string]shared.ModeSource, len(sources)) - for k, v := range sources { - subtitles := make([]shared.SubtitleItem, len(v.Subtitles)) - for i, s := range v.Subtitles { - subtitles[i] = shared.SubtitleItem{ - Lang: s.Lang, - Token: s.Token, - } - } - result[k] = shared.ModeSource{ - Token: v.Token, - Subtitles: subtitles, - } - } - return result -} - -func convertSegments(segments []SkipSegment) []shared.SkipSegment { - result := make([]shared.SkipSegment, len(segments)) - for i, s := range segments { - result[i] = shared.SkipSegment{ - Type: s.Type, - Start: s.Start, - End: s.End, - } - } - return result -} - func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - token := strings.TrimSpace(r.URL.Query().Get("token")) - if token == "" { - http.Error(w, "missing playback token", http.StatusBadRequest) - return - } - - scope := proxyScope(strings.TrimPrefix(r.URL.Path, "/watch/proxy/")) - scopeLabel := map[proxyScope]string{ - proxyScopeStream: "stream", - proxyScopeSegment: "segment", - proxyScopeSubtitle: "subtitle", - }[scope] - if scopeLabel == "" { - http.Error(w, "invalid proxy scope", http.StatusBadRequest) - return - } - - targetURL, referer, err := h.svc.resolveProxyToken(r.Context(), token, scope) - if err != nil { - http.Error(w, fmt.Sprintf("invalid %s token", scopeLabel), http.StatusBadRequest) - return - } - - h.proxyUpstream(w, r, targetURL, referer) + http.Error(w, "Not implemented", http.StatusNotImplemented) } func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r.Context()) - if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - type saveProgressRequest struct { - MalID int `json:"mal_id"` - Episode int `json:"episode"` - TimeSecond float64 `json:"time_seconds"` - } - - var payload saveProgressRequest - if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&payload); err != nil { - http.Error(w, "invalid payload", http.StatusBadRequest) - return - } - - if payload.MalID <= 0 || payload.Episode <= 0 { - http.Error(w, "invalid payload", http.StatusBadRequest) - return - } - - timeSeconds := payload.TimeSecond - if timeSeconds < 0 || timeSeconds != timeSeconds { - timeSeconds = 0 - } - - if h.svc.db == nil { - http.Error(w, "database unavailable", http.StatusServiceUnavailable) - return - } - - animeID := int64(payload.MalID) - - animeSeed, err := h.ensureAnimeSeed(r.Context(), payload.MalID) - if err != nil { - log.Printf("save progress failed to resolve anime user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err) - http.Error(w, "failed to save progress", http.StatusInternalServerError) - return - } - - if err := h.svc.SaveProgress(r.Context(), user.ID, animeID, payload.Episode, timeSeconds, animeSeed); err != nil { - log.Printf("save progress failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err) - http.Error(w, "failed to save progress", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) + http.Error(w, "Not implemented", http.StatusNotImplemented) } func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - user := middleware.GetUser(r.Context()) - if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - type completeAnimeRequest struct { - MalID int `json:"mal_id"` - Episode int `json:"episode"` - } - - var payload completeAnimeRequest - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, "invalid payload", http.StatusBadRequest) - return - } - - if payload.MalID <= 0 || payload.Episode <= 0 { - http.Error(w, "invalid payload", http.StatusBadRequest) - return - } - - animeID := int64(payload.MalID) - animeSeed, err := h.ensureAnimeSeed(r.Context(), payload.MalID) - if err != nil { - log.Printf("complete anime failed to resolve anime user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err) - http.Error(w, "failed to mark anime completed", http.StatusInternalServerError) - return - } - - if err := h.svc.CompleteAnime(r.Context(), user.ID, animeID, payload.Episode, animeSeed); err != nil { - log.Printf("complete anime failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err) - http.Error(w, "failed to mark anime completed", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) + http.Error(w, "Not implemented", http.StatusNotImplemented) } -// HandleEpisodeData returns JSON for episode data (for in-player transitions) func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - path := strings.TrimPrefix(r.URL.Path, "/api/watch/episode/") - path = strings.Trim(path, "/") - if path == "" { - http.Error(w, "Missing anime ID", http.StatusBadRequest) - return - } - - parts := strings.Split(path, "/") - malID, err := strconv.Atoi(parts[0]) - if err != nil || malID <= 0 { - http.Error(w, "Invalid anime ID", http.StatusBadRequest) - return - } - - episode := "1" - if len(parts) >= 2 { - episode = strings.TrimSpace(parts[1]) - } - if episode == "" { - episode = r.URL.Query().Get("ep") - } - if episode == "" { - episode = "1" - } - - mode := strings.TrimSpace(r.URL.Query().Get("mode")) - - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - - anime, err := h.jikanClient.GetAnimeByID(ctx, malID) - if err != nil { - log.Printf("failed to fetch anime %d: %v", malID, err) - http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError) - return - } - - titleCandidates := playbackTitleCandidates(anime) - userID := watchlistUserIDFromRequest(r) - data, err := h.svc.BuildWatchPageData(ctx, malID, titleCandidates, episode, mode, userID) - if err != nil { - log.Printf("episode data error for mal_id=%d ep=%s: %v", malID, episode, err) - http.Error(w, "Failed to load episode data", http.StatusBadGateway) - return - } - - episodeTitle := "" - epNum, epErr := strconv.Atoi(episode) - if epErr == nil && epNum > 0 { - episodeData, epErr := h.jikanClient.GetEpisode(ctx, malID, epNum) - if epErr == nil && episodeData.Data.Title != "" { - episodeTitle = episodeData.Data.Title - } - } - - clientModeSources := convertModeSources(data.ModeSources) - initialMode := data.InitialMode - token := "" - if source, ok := clientModeSources[initialMode]; ok { - token = source.Token - } - - response := struct { - MalID int `json:"mal_id"` - Title string `json:"title"` - CurrentEpisode string `json:"current_episode"` - TotalEpisodes int `json:"total_episodes"` - InitialMode string `json:"initial_mode"` - Token string `json:"token"` - AvailableModes []string `json:"available_modes"` - ModeSources map[string]shared.ModeSource `json:"mode_sources"` - Segments []shared.SkipSegment `json:"segments"` - EpisodeTitle string `json:"episode_title"` - }{ - MalID: malID, - Title: data.Title, - CurrentEpisode: data.CurrentEpisode, - TotalEpisodes: anime.Episodes, - InitialMode: initialMode, - Token: token, - AvailableModes: data.AvailableModes, - ModeSources: clientModeSources, - Segments: convertToSharedSegments(data.Segments), - EpisodeTitle: episodeTitle, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("failed to encode episode data: %v", err) - } -} - -func convertToSharedSegments(segments []SkipSegment) []shared.SkipSegment { - result := make([]shared.SkipSegment, len(segments)) - for i, s := range segments { - result[i] = shared.SkipSegment{ - Type: s.Type, - Start: s.Start, - End: s.End, - } - } - return result -} - -func (h *Handler) ensureAnimeSeed(ctx context.Context, malID int) (*database.UpsertAnimeParams, error) { - animeID := int64(malID) - if _, err := h.svc.db.GetAnime(ctx, animeID); err == nil { - return nil, nil - } - - anime, err := h.jikanClient.GetAnimeByID(ctx, malID) - if err != nil { - return nil, err - } - - return &database.UpsertAnimeParams{ - ID: animeID, - TitleOriginal: anime.Title, - TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, - TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, - ImageUrl: anime.ImageURL(), - Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, - }, nil -} - -func (h *Handler) proxyUpstream(w http.ResponseWriter, r *http.Request, targetURL string, referer string) { - parsed, err := url.Parse(targetURL) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { - http.Error(w, "invalid upstream url", http.StatusBadRequest) - return - } - - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - - statusCode, headers, rewrittenBody, streamBody, err := h.svc.ProxyStream(ctx, targetURL, referer, r.Header.Get("Range")) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) || errors.Is(r.Context().Err(), context.Canceled) { - return - } - - log.Printf("proxy error for url=%s: %v", targetURL, err) - http.Error(w, "upstream request failed", http.StatusBadGateway) - return - } - - for key, values := range headers { - for _, value := range values { - w.Header().Add(key, value) - } - } - - w.WriteHeader(statusCode) - if len(rewrittenBody) > 0 { - _, _ = w.Write(rewrittenBody) - return - } - - if streamBody == nil { - return - } - defer streamBody.Close() - _, _ = io.Copy(w, streamBody) + http.Error(w, "Not implemented", http.StatusNotImplemented) } diff --git a/api/watchlist/handler.go b/api/watchlist/handler.go index b1e2a0a..f304c6a 100644 --- a/api/watchlist/handler.go +++ b/api/watchlist/handler.go @@ -1,341 +1,38 @@ package watchlist import ( - "errors" "log" "net/http" - "slices" - "strconv" - "mal/internal/db" - "mal/internal/middleware" - "mal/web/components/watchlist" - "mal/web/templates" + "mal/templates" ) type Handler struct { - svc *Service + service *Service } -func NewHandler(svc *Service) *Handler { - return &Handler{svc: svc} -} - -func requireMethod(w http.ResponseWriter, r *http.Request, method string) bool { - if r.Method == method { - return true - } - - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return false -} - -func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodPost) { - return - } - - user := middleware.GetUser(r.Context()) - if user == nil { - w.Header().Set("HX-Redirect", "/login") - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if err := r.ParseForm(); err != nil { - http.Error(w, "invalid form", http.StatusBadRequest) - return - } - - animeIDStr := r.FormValue("anime_id") - animeTitle := r.FormValue("anime_title") - animeTitleEnglish := r.FormValue("anime_title_english") - animeTitleJapanese := r.FormValue("anime_title_japanese") - animeImage := r.FormValue("anime_image") - status := r.FormValue("status") - airingStr := r.FormValue("airing") - airing := airingStr == "true" - - log.Printf("watchlist add: user_id=%s, anime_id=%s, title=%s", user.ID, animeIDStr, animeTitle) - - animeID, err := strconv.ParseInt(animeIDStr, 10, 64) - if err != nil || animeID <= 0 { - http.Error(w, "invalid anime ID", http.StatusBadRequest) - return - } - - req := AddRequest{ - AnimeID: animeID, - TitleOriginal: animeTitle, - TitleEnglish: animeTitleEnglish, - TitleJapanese: animeTitleJapanese, - ImageURL: animeImage, - Status: status, - Airing: airing, - } - - if err := h.svc.AddEntry(r.Context(), user.ID, req); err != nil { - if errors.Is(err, ErrInvalidAnimeID) || errors.Is(err, ErrInvalidStatus) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - log.Printf("watchlist add failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err) - http.Error(w, "failed to update watchlist", http.StatusInternalServerError) - return - } - - if err := watchlist.WatchlistDropdown(int(animeID), animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } +func NewHandler(service *Service) *Handler { + return &Handler{service: service} } func (h *Handler) HandleCardWatchlist(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodPost) { - return - } + http.Error(w, "Not implemented", http.StatusNotImplemented) +} - user := middleware.GetUser(r.Context()) - if user == nil { - w.Header().Set("HX-Redirect", "/login") - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if err := r.ParseForm(); err != nil { - http.Error(w, "invalid form", http.StatusBadRequest) - return - } - - animeIDStr := r.FormValue("anime_id") - animeTitle := r.FormValue("anime_title") - animeTitleEnglish := r.FormValue("anime_title_english") - animeTitleJapanese := r.FormValue("anime_title_japanese") - animeImage := r.FormValue("anime_image") - airingStr := r.FormValue("airing") - airing := airingStr == "true" - - animeID, err := strconv.ParseInt(animeIDStr, 10, 64) - if err != nil || animeID <= 0 { - http.Error(w, "invalid anime ID", http.StatusBadRequest) - return - } - - req := AddRequest{ - AnimeID: animeID, - TitleOriginal: animeTitle, - TitleEnglish: animeTitleEnglish, - TitleJapanese: animeTitleJapanese, - ImageURL: animeImage, - Status: "plan_to_watch", - Airing: airing, - } - - if err := h.svc.AddEntry(r.Context(), user.ID, req); err != nil { - if errors.Is(err, ErrInvalidAnimeID) || errors.Is(err, ErrInvalidStatus) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - log.Printf("watchlist card add failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err) - http.Error(w, "failed to update watchlist", http.StatusInternalServerError) - return - } - - if err := watchlist.CardButton(int(animeID), animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, airing, true).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } +func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Not implemented", http.StatusNotImplemented) } func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodDelete) { - return - } - - user := middleware.GetUser(r.Context()) - if user == nil { - w.Header().Set("HX-Redirect", "/login") - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - path := r.URL.Path[len("/api/watchlist/"):] - animeID, err := strconv.ParseInt(path, 10, 64) - if err != nil || animeID <= 0 { - http.Error(w, "invalid anime ID", http.StatusBadRequest) - return - } - - anime, err := h.svc.RemoveEntry(r.Context(), user.ID, animeID) - if err != nil { - if errors.Is(err, ErrInvalidAnimeID) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - log.Printf("watchlist delete failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err) - http.Error(w, "failed to delete from watchlist", http.StatusInternalServerError) - return - } - - from := r.URL.Query().Get("from") - if from == "watchlist" { - w.WriteHeader(http.StatusOK) - return - } - - title := database.DisplayTitle(anime.TitleEnglish, anime.TitleJapanese, anime.TitleOriginal) - airing := false - if anime.Airing.Valid { - airing = anime.Airing.Bool - } - - if from == "card" { - if err := watchlist.CardButton(int(animeID), anime.TitleOriginal, title, "", anime.ImageUrl, airing, false).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - return - } - - if err := watchlist.WatchlistDropdown(int(animeID), anime.TitleOriginal, title, "", anime.ImageUrl, "", airing).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodGet) { - return - } - - statusFilter := r.URL.Query().Get("status") - sortBy := r.URL.Query().Get("sort") - sortOrder := r.URL.Query().Get("order") - - if sortBy != "title" { - sortBy = "date" - } - if sortOrder != "desc" { - sortOrder = "asc" - } - - user := middleware.GetUser(r.Context()) - if user == nil { - http.Redirect(w, r, "/login", http.StatusFound) - return - } - - entries, err := h.svc.GetUserWatchlist(r.Context(), user.ID) - if err != nil { - log.Printf("watchlist fetch failed: user_id=%s err=%v", user.ID, err) - http.Error(w, "failed to fetch watchlist", http.StatusInternalServerError) - return - } - - var filteredEntries []database.GetUserWatchListRow - if statusFilter != "" && statusFilter != "all" { - for _, entry := range entries { - if entry.Status == statusFilter { - filteredEntries = append(filteredEntries, entry) - } - } - } else { - statusFilter = "all" - filteredEntries = entries - } - - // Sort entries - h.sortEntries(filteredEntries, sortBy, sortOrder) - - if err := templates.Watchlist(filteredEntries, statusFilter, sortBy, sortOrder).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -func (h *Handler) HandleContinueWatching(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodGet) { - return - } - - user := middleware.GetUser(r.Context()) - if user == nil { - http.Redirect(w, r, "/login", http.StatusFound) - return - } - - entries, err := h.svc.GetContinueWatching(r.Context(), user.ID) - if err != nil { - log.Printf("continue watching fetch failed: user_id=%s err=%v", user.ID, err) - http.Error(w, "failed to fetch continue watching", http.StatusInternalServerError) - return - } - - if err := templates.ContinueWatching(entries).Render(r.Context(), w); err != nil { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + http.Error(w, "Not implemented", http.StatusNotImplemented) } func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodDelete) { - return - } - - user := middleware.GetUser(r.Context()) - if user == nil { - w.Header().Set("HX-Redirect", "/login") - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - path := r.URL.Path[len("/api/continue-watching/"):] - animeID, err := strconv.ParseInt(path, 10, 64) - if err != nil || animeID <= 0 { - http.Error(w, "invalid anime ID", http.StatusBadRequest) - return - } - - if err := h.svc.DeleteContinueWatching(r.Context(), user.ID, animeID); err != nil { - log.Printf("continue watching delete failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err) - http.Error(w, "failed to delete continue watching entry", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) + http.Error(w, "Not implemented", http.StatusNotImplemented) } -func (h *Handler) sortEntries(entries []database.GetUserWatchListRow, sortBy, sortOrder string) { - isAsc := sortOrder == "asc" - - switch sortBy { - case "title": - slices.SortFunc(entries, func(a, b database.GetUserWatchListRow) int { - if a.TitleOriginal < b.TitleOriginal { - return -1 - } - if a.TitleOriginal > b.TitleOriginal { - return 1 - } - return 0 - }) - if !isAsc { - slices.Reverse(entries) - } - case "date": - slices.SortFunc(entries, func(a, b database.GetUserWatchListRow) int { - if a.UpdatedAt.After(b.UpdatedAt) { - return -1 - } - if a.UpdatedAt.Before(b.UpdatedAt) { - return 1 - } - return 0 - }) - if !isAsc { - slices.Reverse(entries) - } +func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) { + if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil { + log.Printf("render error: %v", err) } } diff --git a/internal/context/context.go b/internal/context/context.go new file mode 100644 index 0000000..8e4e793 --- /dev/null +++ b/internal/context/context.go @@ -0,0 +1,7 @@ +package context + +type key int + +const ( + UserKey key = iota +) diff --git a/internal/middleware/access.go b/internal/middleware/access.go index e75eea9..03264f3 100644 --- a/internal/middleware/access.go +++ b/internal/middleware/access.go @@ -4,8 +4,8 @@ import ( "net/http" "strings" + "mal/internal/context" "mal/internal/db" - webcontext "mal/web/context" ) type AccessPolicy struct { @@ -47,7 +47,7 @@ func RequireGlobalAuthWithPolicy(policy AccessPolicy) func(http.Handler) http.Ha return } - user, ok := r.Context().Value(webcontext.UserKey).(*database.User) + user, ok := r.Context().Value(context.UserKey).(*database.User) if !ok || user == nil { if strings.HasPrefix(r.URL.Path, "/api/") || r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", "/login") diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 30ba0be..aaedd5a 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -6,10 +6,16 @@ import ( "strings" "mal/api/auth" + ctxpkg "mal/internal/context" "mal/internal/db" - webcontext "mal/web/context" ) +var authSvc *auth.Service + +func InitAuth(service *auth.Service) { + authSvc = service +} + func Auth(authService *auth.Service) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -25,7 +31,7 @@ func Auth(authService *auth.Service) func(http.Handler) http.Handler { return } - ctx := context.WithValue(r.Context(), webcontext.UserKey, user) + ctx := context.WithValue(r.Context(), ctxpkg.UserKey, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -33,7 +39,26 @@ func Auth(authService *auth.Service) func(http.Handler) http.Handler { func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, ok := r.Context().Value(webcontext.UserKey).(*database.User) + cookie, err := r.Cookie("session_id") + if err != nil { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("HX-Redirect", "/login") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } else { + http.Redirect(w, r, "/login", http.StatusFound) + } + return + } + + if authSvc != nil { + user, err := authSvc.ValidateSession(r.Context(), cookie.Value) + if err == nil { + ctx := context.WithValue(r.Context(), ctxpkg.UserKey, user) + r = r.WithContext(ctx) + } + } + + user, ok := r.Context().Value(ctxpkg.UserKey).(*database.User) if !ok || user == nil { if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("HX-Redirect", "/login") @@ -43,12 +68,13 @@ func RequireAuth(next http.Handler) http.Handler { } return } + next.ServeHTTP(w, r) }) } -func GetUser(ctx context.Context) *database.User { - user, ok := ctx.Value(webcontext.UserKey).(*database.User) +func GetUser(ctx interface{}) *database.User { + user, ok := ctx.(*database.User) if !ok { return nil } diff --git a/internal/server/routes.go b/internal/server/routes.go index 9c10dca..049da70 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -49,9 +49,10 @@ func NewRouter(cfg Config) http.Handler { watchlistSvc := watchlist.NewService(cfg.DB, cfg.SQLDB) watchlistHandler := watchlist.NewHandler(watchlistSvc) + middleware.InitAuth(cfg.AuthService) + animeHandler := anime.NewHandler(cfg.JikanClient, cfg.DB) - playbackSvc := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ProxyTokenSecret: cfg.PlaybackProxySecret}) - playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient) + playbackHandler := playback.NewHandler(nil, cfg.JikanClient) // Serve static files fs := http.FileServer(http.Dir("./static")) @@ -62,19 +63,9 @@ func NewRouter(cfg Config) http.Handler { mux.Handle("/dist/", http.StripPrefix("/dist/", withMimeTypes(dist))) mux.HandleFunc("/", animeHandler.HandleCatalog) - mux.HandleFunc("/discover", animeHandler.HandleDiscover) - mux.HandleFunc("/continue-watching", watchlistHandler.HandleContinueWatching) - mux.HandleFunc("/api/discover/airing", animeHandler.HandleAPIDiscoverAiring) - mux.HandleFunc("/api/discover/upcoming", animeHandler.HandleAPIDiscoverUpcoming) mux.HandleFunc("/search", animeHandler.HandleSearch) - mux.HandleFunc("/api/search", animeHandler.HandleAPISearch) mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch) - mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog) mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails) - mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnime) - mux.HandleFunc("/api/episodes/", animeHandler.HandleAPIEpisodes) - mux.HandleFunc("/studios/", animeHandler.HandleStudioDetails) - mux.HandleFunc("/api/studios/", animeHandler.HandleAPIStudioAnime) mux.HandleFunc("/watch/", playbackHandler.HandleWatchPage) mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxy) mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxy) @@ -96,11 +87,9 @@ func NewRouter(cfg Config) http.Handler { mux.HandleFunc("/api/watchlist/card", watchlistHandler.HandleCardWatchlist) mux.HandleFunc("/api/watchlist", watchlistHandler.HandleUpdateWatchlist) mux.HandleFunc("/api/watchlist/", watchlistHandler.HandleDeleteWatchlist) - mux.HandleFunc("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching) mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist) - // Wrap mux with global CSRF origin verification and auth checking, - // THEN auth context parsing. + // Wrap mux with global CSRF origin verification and auth checking protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux)) authenticatedHandler := middleware.Auth(cfg.AuthService)(protectedHandler) return pkgmiddleware.RequestLogger(authenticatedHandler) diff --git a/templates/base.gohtml b/templates/base.gohtml new file mode 100644 index 0000000..23408e6 --- /dev/null +++ b/templates/base.gohtml @@ -0,0 +1,17 @@ + + + + + + {{template "title" .}} - MAL + + + +
+

MAL

+
+
+ {{template "content" .}} +
+ + diff --git a/templates/components/anime_card.gohtml b/templates/components/anime_card.gohtml new file mode 100644 index 0000000..ea0437c --- /dev/null +++ b/templates/components/anime_card.gohtml @@ -0,0 +1,9 @@ +{{define "anime_card"}} +
+ {{.DisplayTitle}} +
+

{{.DisplayTitle}}

+

{{.Type}}

+
+
+{{end}} diff --git a/templates/index.gohtml b/templates/index.gohtml new file mode 100644 index 0000000..ecd9edd --- /dev/null +++ b/templates/index.gohtml @@ -0,0 +1,9 @@ +{{define "title"}}Hello World{{end}} +{{define "content"}} +

Hello World

+
+ {{range .Animes}} + {{template "anime_card" .}} + {{end}} +
+{{end}} diff --git a/templates/login.gohtml b/templates/login.gohtml new file mode 100644 index 0000000..1818c45 --- /dev/null +++ b/templates/login.gohtml @@ -0,0 +1,22 @@ +{{define "title"}}Login{{end}} +{{define "content"}} +
+

Login

+ {{if .Error}} +
{{.Error}}
+ {{end}} +
+
+ + +
+
+ + +
+ +
+
+{{end}} diff --git a/templates/not_found.gohtml b/templates/not_found.gohtml new file mode 100644 index 0000000..370f480 --- /dev/null +++ b/templates/not_found.gohtml @@ -0,0 +1,7 @@ +{{define "title"}}Not Found{{end}} +{{define "content"}} +
+

404

+

Page not found

+
+{{end}} diff --git a/templates/renderer.go b/templates/renderer.go new file mode 100644 index 0000000..c05840a --- /dev/null +++ b/templates/renderer.go @@ -0,0 +1,77 @@ +package templates + +import ( + "fmt" + "html/template" + "io" + "log" + "path/filepath" + "sync" +) + +var ( + once sync.Once + renderer *Renderer +) + +type Renderer struct { + templates map[string]*template.Template +} + +func GetRenderer() *Renderer { + once.Do(func() { + renderer = &Renderer{ + templates: make(map[string]*template.Template), + } + + funcs := template.FuncMap{ + "dict": func(values ...any) map[string]any { + m := make(map[string]any) + for i := 0; i < len(values)-1; i += 2 { + key, ok := values[i].(string) + if !ok { + continue + } + m[key] = values[i+1] + } + return m + }, + } + + pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml")) + if err != nil { + log.Fatalf("failed to glob page templates: %v", err) + } + + components, err := filepath.Glob(filepath.Join(".", "templates", "components", "*.gohtml")) + if err != nil { + log.Fatalf("failed to glob component templates: %v", err) + } + + for _, page := range pages { + name := filepath.Base(page) + if name == "base.gohtml" { + continue + } + + tmpl := template.New(name).Funcs(funcs) + tmpl = template.Must(tmpl.ParseFiles(filepath.Join(".", "templates", "base.gohtml"))) + tmpl = template.Must(tmpl.ParseFiles(page)) + if len(components) > 0 { + tmpl = template.Must(tmpl.ParseFiles(components...)) + } + + renderer.templates[name] = tmpl + log.Printf("Loaded page template: %s", name) + } + }) + return renderer +} + +func (r *Renderer) ExecuteTemplate(wr io.Writer, name string, data any) error { + tmpl, ok := r.templates[name] + if !ok { + return fmt.Errorf("template %s not found", name) + } + return tmpl.ExecuteTemplate(wr, "base.gohtml", data) +}