790 lines
25 KiB
Go
790 lines
25 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
|
"github.com/Silo-Server/silo-server/internal/watchtogether"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
type WatchTogetherScopeResolver interface {
|
|
Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error)
|
|
}
|
|
|
|
type WatchTogetherHandler struct {
|
|
Service *watchtogether.Service
|
|
ScopeResolver WatchTogetherScopeResolver
|
|
TokenService *watchtogether.RoomTokenService
|
|
}
|
|
|
|
type createWatchTogetherRoomRequest struct {
|
|
SelectionMode string `json:"selection_mode,omitempty"`
|
|
}
|
|
|
|
type joinWatchTogetherRoomRequest struct {
|
|
Code string `json:"code"`
|
|
JoinToken string `json:"join_token"`
|
|
}
|
|
|
|
type updateWatchTogetherPolicyRequest struct {
|
|
GuestControlPolicy watchtogether.GuestControlPolicy `json:"guest_control_policy"`
|
|
}
|
|
|
|
type selectWatchTogetherRoomItemRequest struct {
|
|
ContentID string `json:"content_id"`
|
|
FileID *int `json:"file_id"`
|
|
LibraryID *int `json:"library_id"`
|
|
}
|
|
|
|
type watchTogetherRoomResponse struct {
|
|
Room watchtogether.Snapshot `json:"room"`
|
|
RoomAccessToken string `json:"room_access_token,omitempty"`
|
|
}
|
|
|
|
type createWatchTogetherSuggestionRequest struct {
|
|
ContentID string `json:"content_id"`
|
|
ContentType string `json:"content_type"`
|
|
Title string `json:"title"`
|
|
Subtitle string `json:"subtitle,omitempty"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
type promoteSuggestionRequest struct {
|
|
SuggestionID string `json:"suggestion_id"`
|
|
}
|
|
|
|
type watchTogetherSuggestionsResponse struct {
|
|
Suggestions []watchtogether.Suggestion `json:"suggestions"`
|
|
}
|
|
|
|
type watchTogetherClientMessage struct {
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type watchTogetherAttachMessage struct {
|
|
Type string `json:"type"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
type watchTogetherTransportRequestMessage struct {
|
|
Type string `json:"type"`
|
|
Action watchtogether.TransportAction `json:"action"`
|
|
PositionSeconds *float64 `json:"position_seconds,omitempty"`
|
|
IsPaused bool `json:"is_paused"`
|
|
}
|
|
|
|
type watchTogetherStateReportMessage struct {
|
|
Type string `json:"type"`
|
|
SessionID string `json:"session_id"`
|
|
PositionSeconds float64 `json:"position_seconds"`
|
|
IsPaused bool `json:"is_paused"`
|
|
}
|
|
|
|
type watchTogetherReadyMessage struct {
|
|
Type string `json:"type"`
|
|
SessionID string `json:"session_id"`
|
|
PositionSeconds float64 `json:"position_seconds"`
|
|
IsPaused bool `json:"is_paused"`
|
|
}
|
|
|
|
type watchTogetherBufferingMessage struct {
|
|
Type string `json:"type"`
|
|
SessionID string `json:"session_id"`
|
|
PositionSeconds float64 `json:"position_seconds"`
|
|
IsPaused bool `json:"is_paused"`
|
|
}
|
|
|
|
type watchTogetherPingMessage struct {
|
|
Type string `json:"type"`
|
|
ClientSentAt string `json:"client_sent_at"`
|
|
}
|
|
|
|
type watchTogetherRoomConn struct {
|
|
conn *websocket.Conn
|
|
writeMu sync.Mutex
|
|
}
|
|
|
|
func (c *watchTogetherRoomConn) WriteJSON(v any) error {
|
|
c.writeMu.Lock()
|
|
defer c.writeMu.Unlock()
|
|
return writeWebSocketJSON(c.conn, v)
|
|
}
|
|
|
|
func (c *watchTogetherRoomConn) Close() error {
|
|
c.writeMu.Lock()
|
|
defer c.writeMu.Unlock()
|
|
return c.conn.Close()
|
|
}
|
|
|
|
func (c *watchTogetherRoomConn) WritePing() error {
|
|
c.writeMu.Lock()
|
|
defer c.writeMu.Unlock()
|
|
return writeWebSocketControl(c.conn, websocket.PingMessage, nil)
|
|
}
|
|
|
|
func NewWatchTogetherHandler(
|
|
service *watchtogether.Service,
|
|
scopeResolver WatchTogetherScopeResolver,
|
|
tokenService *watchtogether.RoomTokenService,
|
|
) *WatchTogetherHandler {
|
|
return &WatchTogetherHandler{
|
|
Service: service,
|
|
ScopeResolver: scopeResolver,
|
|
TokenService: tokenService,
|
|
}
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleCreateRoom(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
if userID == 0 || profileID == "" {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
var req createWatchTogetherRoomRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
room, err := h.Service.CreateRoom(r.Context(), watchtogether.CreateRoomInput{
|
|
HostUserID: userID,
|
|
HostProfileID: profileID,
|
|
SelectionMode: watchtogether.RoomSelectionMode(req.SelectionMode),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create room")
|
|
return
|
|
}
|
|
|
|
snapshot, err := h.Service.Snapshot(r.Context(), room.ID, userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room")
|
|
return
|
|
}
|
|
|
|
response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, response)
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
if userID == 0 || profileID == "" {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
var req joinWatchTogetherRoomRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
room, err := h.Service.JoinRoom(r.Context(), watchtogether.JoinInput{
|
|
Code: req.Code,
|
|
JoinToken: req.JoinToken,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrInvalidJoinRequest):
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Room code or invite token is required")
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
case errors.Is(err, watchtogether.ErrRoomClosed):
|
|
writeError(w, http.StatusGone, "gone", "Room is no longer active")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to join room")
|
|
}
|
|
return
|
|
}
|
|
|
|
snapshot, err := h.Service.Snapshot(r.Context(), room.ID, userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room")
|
|
return
|
|
}
|
|
|
|
response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleGetRoom(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
if userID == 0 || profileID == "" {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
roomID := chi.URLParam(r, "room_id")
|
|
if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Room access token required")
|
|
return
|
|
}
|
|
snapshot, err := h.Service.Snapshot(r.Context(), roomID, userID, profileID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
case errors.Is(err, watchtogether.ErrRoomClosed):
|
|
writeError(w, http.StatusGone, "gone", "Room is no longer active")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room")
|
|
}
|
|
return
|
|
}
|
|
|
|
response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleUpdateRoomPolicy(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
|
|
var req updateWatchTogetherPolicyRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
snapshot, err := h.Service.UpdatePolicy(r.Context(), roomID, userID, profileID, req.GuestControlPolicy)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrRoomForbidden):
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only the host can update room policy")
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update room policy")
|
|
}
|
|
return
|
|
}
|
|
|
|
response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleCloseRoom(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
|
|
if err := h.Service.CloseRoom(r.Context(), roomID, userID, profileID); err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrRoomForbidden):
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only the host can close the room")
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to close room")
|
|
}
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleSelectRoomItem(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
|
|
var req selectWatchTogetherRoomItemRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
snapshot, err := h.Service.SelectItem(r.Context(), roomID, userID, profileID, watchtogether.SelectItemInput{
|
|
ContentID: req.ContentID,
|
|
FileID: req.FileID,
|
|
LibraryID: req.LibraryID,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrRoomForbidden):
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only the host can start or switch room playback")
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
case errors.Is(err, watchtogether.ErrRoomClosed):
|
|
writeError(w, http.StatusGone, "gone", "Room is no longer active")
|
|
case errors.Is(err, watchtogether.ErrInvalidSelection):
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Content is not playable in this room")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update room selection")
|
|
}
|
|
return
|
|
}
|
|
|
|
response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleListSuggestions(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
|
|
if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Room access token required")
|
|
return
|
|
}
|
|
|
|
suggestions, err := h.Service.ListSuggestions(r.Context(), roomID, profileID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
case errors.Is(err, watchtogether.ErrRoomClosed):
|
|
writeError(w, http.StatusGone, "gone", "Room is no longer active")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list suggestions")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions})
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleCreateSuggestion(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
|
|
if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Room access token required")
|
|
return
|
|
}
|
|
|
|
var req createWatchTogetherSuggestionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.ContentID == "" || req.Title == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "content_id and title are required")
|
|
return
|
|
}
|
|
|
|
suggestions, err := h.Service.CreateSuggestion(r.Context(), roomID, userID, profileID, watchtogether.CreateSuggestionInput{
|
|
ContentID: req.ContentID,
|
|
ContentType: req.ContentType,
|
|
Title: req.Title,
|
|
Subtitle: req.Subtitle,
|
|
PosterURL: req.PosterURL,
|
|
Note: req.Note,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
case errors.Is(err, watchtogether.ErrRoomClosed):
|
|
writeError(w, http.StatusGone, "gone", "Room is no longer active")
|
|
case errors.Is(err, watchtogether.ErrInvalidSelection):
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid content type")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create suggestion")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, watchTogetherSuggestionsResponse{Suggestions: suggestions})
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleDeleteSuggestion(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
suggestionID := chi.URLParam(r, "suggestion_id")
|
|
|
|
if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Room access token required")
|
|
return
|
|
}
|
|
|
|
suggestions, err := h.Service.DeleteSuggestion(r.Context(), roomID, suggestionID, userID, profileID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrSuggestionNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Suggestion not found")
|
|
case errors.Is(err, watchtogether.ErrRoomForbidden):
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only the host or suggester can delete a suggestion")
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete suggestion")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions})
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleVote(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
suggestionID := chi.URLParam(r, "suggestion_id")
|
|
|
|
if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Room access token required")
|
|
return
|
|
}
|
|
|
|
suggestions, err := h.Service.Vote(r.Context(), roomID, suggestionID, userID, profileID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrDuplicateVote):
|
|
writeError(w, http.StatusConflict, "conflict", "Already voted")
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to vote")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions})
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleUnvote(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
suggestionID := chi.URLParam(r, "suggestion_id")
|
|
|
|
if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Room access token required")
|
|
return
|
|
}
|
|
|
|
suggestions, err := h.Service.Unvote(r.Context(), roomID, suggestionID, userID, profileID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrNotVoted):
|
|
writeError(w, http.StatusConflict, "conflict", "Not voted")
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove vote")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions})
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandlePromoteSuggestion(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
roomID := chi.URLParam(r, "room_id")
|
|
|
|
if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Room access token required")
|
|
return
|
|
}
|
|
|
|
var req promoteSuggestionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.SuggestionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "suggestion_id is required")
|
|
return
|
|
}
|
|
|
|
snapshot, err := h.Service.PromoteSuggestion(r.Context(), roomID, req.SuggestionID, userID, profileID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, watchtogether.ErrRoomForbidden):
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only the host can promote a suggestion")
|
|
case errors.Is(err, watchtogether.ErrSuggestionNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Suggestion not found")
|
|
case errors.Is(err, watchtogether.ErrRoomNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Room not found")
|
|
case errors.Is(err, watchtogether.ErrInvalidSelection):
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Suggested content is not playable")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to promote suggestion")
|
|
}
|
|
return
|
|
}
|
|
|
|
response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) buildRoomResponse(
|
|
ctx context.Context,
|
|
snapshot watchtogether.Snapshot,
|
|
userID int,
|
|
profileID string,
|
|
) (watchTogetherRoomResponse, error) {
|
|
response := watchTogetherRoomResponse{Room: snapshot}
|
|
if h == nil || h.TokenService == nil {
|
|
return response, nil
|
|
}
|
|
|
|
token, _, err := h.TokenService.Mint(watchtogether.RoomTokenClaims{
|
|
RoomID: snapshot.RoomID,
|
|
UserID: userID,
|
|
ProfileID: profileID,
|
|
})
|
|
if err != nil {
|
|
return watchTogetherRoomResponse{}, err
|
|
}
|
|
response.RoomAccessToken = token
|
|
return response, nil
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) validateRoomAccessToken(
|
|
r *http.Request,
|
|
roomID string,
|
|
userID int,
|
|
profileID string,
|
|
) error {
|
|
if h == nil || h.TokenService == nil {
|
|
return nil
|
|
}
|
|
|
|
claims, err := h.TokenService.Validate(r.URL.Query().Get("room_token"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if claims.RoomID != roomID || claims.UserID != userID || claims.ProfileID != profileID {
|
|
return watchtogether.ErrRoomForbidden
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) HandleRoomWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
if h == nil || h.Service == nil || h.ScopeResolver == nil {
|
|
http.Error(w, "watch together unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
claims := apimw.GetClaims(r.Context())
|
|
if claims == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
roomID := chi.URLParam(r, "room_id")
|
|
if roomID == "" {
|
|
http.Error(w, "room_id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
profileID := r.URL.Query().Get("profile_id")
|
|
if profileID == "" {
|
|
http.Error(w, "profile_id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.validateRoomAccessToken(r, roomID, claims.UserID, profileID); err != nil {
|
|
http.Error(w, "room access token required", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
_, err := h.ScopeResolver.Resolve(r.Context(), access.ResolveInput{
|
|
UserID: claims.UserID,
|
|
SessionID: claims.SessionID,
|
|
ProfileID: profileID,
|
|
ProfileToken: r.URL.Query().Get("profile_token"),
|
|
SkipPINVerification: claims.TokenType == auth.TokenTypeAPIKey,
|
|
})
|
|
if err != nil {
|
|
status := http.StatusForbidden
|
|
if errors.Is(err, access.ErrProfileNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
http.Error(w, "profile verification failed", status)
|
|
return
|
|
}
|
|
|
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
realtimeConn := &watchTogetherRoomConn{conn: conn}
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
|
|
reg, snapshot, err := h.Service.Connect(ctx, roomID, claims.UserID, profileID, realtimeConn)
|
|
if err != nil {
|
|
if errors.Is(err, watchtogether.ErrRoomNotFound) {
|
|
writeWebSocketError(conn, "not_found", "Room not found")
|
|
} else if errors.Is(err, watchtogether.ErrRoomClosed) {
|
|
writeWebSocketError(conn, "gone", "Room is no longer active")
|
|
} else {
|
|
writeWebSocketError(conn, "internal_error", "Failed to connect room socket")
|
|
}
|
|
return
|
|
}
|
|
defer h.Service.Disconnect(reg, false)
|
|
|
|
configureWebSocket(conn)
|
|
startWebSocketPingLoop(ctx, realtimeConn.WritePing)
|
|
|
|
if err := realtimeConn.WriteJSON(map[string]any{
|
|
"type": "snapshot",
|
|
"room": snapshot,
|
|
}); err != nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if err := h.handleRoomClientMessage(ctx, conn, reg, claims.UserID, profileID, data); err != nil {
|
|
writeWebSocketError(conn, "bad_request", err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *WatchTogetherHandler) handleRoomClientMessage(
|
|
ctx context.Context,
|
|
conn *websocket.Conn,
|
|
reg *watchtogether.Registration,
|
|
userID int,
|
|
profileID string,
|
|
data []byte,
|
|
) error {
|
|
var base watchTogetherClientMessage
|
|
if err := json.Unmarshal(data, &base); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch base.Type {
|
|
case "attach_session":
|
|
var msg watchTogetherAttachMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
return err
|
|
}
|
|
if msg.SessionID == "" {
|
|
return errors.New("session_id is required")
|
|
}
|
|
_, err := h.Service.AttachSessionForConnection(ctx, reg, userID, profileID, msg.SessionID)
|
|
return err
|
|
case "transport_request":
|
|
var msg watchTogetherTransportRequestMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
return err
|
|
}
|
|
_, err := h.Service.HandleTransportRequestForConnection(ctx, reg, userID, profileID, watchtogether.TransportRequest{
|
|
Action: msg.Action,
|
|
PositionSeconds: msg.PositionSeconds,
|
|
IsPaused: msg.IsPaused,
|
|
})
|
|
return err
|
|
case "state_report":
|
|
var msg watchTogetherStateReportMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
return err
|
|
}
|
|
if msg.SessionID == "" {
|
|
return errors.New("session_id is required")
|
|
}
|
|
_, err := h.Service.HandleStateReportForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{
|
|
SessionID: msg.SessionID,
|
|
PositionSeconds: msg.PositionSeconds,
|
|
IsPaused: msg.IsPaused,
|
|
})
|
|
return err
|
|
case "ready":
|
|
var msg watchTogetherReadyMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
return err
|
|
}
|
|
if msg.SessionID == "" {
|
|
return errors.New("session_id is required")
|
|
}
|
|
_, err := h.Service.HandleReadyForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{
|
|
SessionID: msg.SessionID,
|
|
PositionSeconds: msg.PositionSeconds,
|
|
IsPaused: msg.IsPaused,
|
|
})
|
|
return err
|
|
case "buffering":
|
|
var msg watchTogetherBufferingMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
return err
|
|
}
|
|
if msg.SessionID == "" {
|
|
return errors.New("session_id is required")
|
|
}
|
|
_, err := h.Service.HandleBufferingForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{
|
|
SessionID: msg.SessionID,
|
|
PositionSeconds: msg.PositionSeconds,
|
|
IsPaused: msg.IsPaused,
|
|
})
|
|
return err
|
|
case "ping":
|
|
var msg watchTogetherPingMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
return err
|
|
}
|
|
clientSentAt, err := time.Parse(time.RFC3339Nano, msg.ClientSentAt)
|
|
if err != nil {
|
|
return errors.New("client_sent_at must be RFC3339Nano")
|
|
}
|
|
now := time.Now().UTC()
|
|
pingMS := now.Sub(clientSentAt.UTC()).Milliseconds()
|
|
if pingMS > 0 {
|
|
_ = h.Service.HandlePingForConnection(ctx, reg, userID, profileID, pingMS)
|
|
}
|
|
return writeWebSocketJSON(conn, map[string]string{
|
|
"type": "pong",
|
|
"client_sent_at": msg.ClientSentAt,
|
|
"server_received_at": now.Format(time.RFC3339Nano),
|
|
"server_sent_at": time.Now().UTC().Format(time.RFC3339Nano),
|
|
})
|
|
default:
|
|
return errors.New("unsupported room websocket message")
|
|
}
|
|
}
|